From 8b31ec5040c4b15fca4f3ae9fe28780ccebb3e9a Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 30 Oct 2025 09:23:21 +0000 Subject: [PATCH 001/555] Chore: Convert to functional components (#112951) * refactor ErrorBoundary so it doesn't trigger the lint rule * refactor ErrorBoundaryAlert to functional component * convert StatPanel to a functional component * convert ServiceAccountPicker to a functional component * convert UserPicker to a functional component * don't need displayName when not memoized * convert TimelineChart to a functional component * convert UserLdapSyncInfo to a functional component * convert UserOrgs to functional component * convert OrgRow to a functional component * convert UserSessions to a functional component * convert TimePickerSettings to a functional component * convert DataSourcePluginSettings to a functional component * convert ExploreTimeControls to a functional component * convert SearchBarInput to a functional component * convert LiveConnectionWarning to a functional component * convert ConcatenateTransformerEditor * convert ConstantVariableEditor a functional component * convert VariableInput to a functional component * convert ConfigEditor to a functional component * convert CSVWavesEditor to a functional component --- eslint-suppressions.json | 86 +---- .../src/components/ErrorBoundary.tsx | 10 +- .../ErrorBoundary/ErrorBoundary.tsx | 23 +- .../Select/ServiceAccountPicker.tsx | 110 +++---- .../app/core/components/Select/UserPicker.tsx | 104 +++--- .../TimelineChart/TimelineChart.tsx | 121 +++---- .../app/features/admin/UserLdapSyncInfo.tsx | 138 ++++---- public/app/features/admin/UserOrgs.tsx | 300 ++++++++---------- public/app/features/admin/UserSessions.tsx | 195 ++++++------ .../DashboardSettings/TimePickerSettings.tsx | 113 ++++--- .../components/DataSourcePluginSettings.tsx | 51 ++- .../features/explore/ExploreTimeControls.tsx | 88 +++-- .../components/common/SearchBarInput.test.tsx | 5 +- .../components/common/SearchBarInput.tsx | 65 ++-- .../features/live/LiveConnectionWarning.tsx | 97 +++--- .../editors/ConcatenateTransformerEditor.tsx | 116 +++---- .../constant/ConstantVariableEditor.tsx | 15 +- .../pickers/shared/VariableInput.tsx | 57 ++-- .../components/ConfigEditor/ConfigEditor.tsx | 62 ++-- .../components/CSVWaveEditor.tsx | 63 ++-- public/app/plugins/panel/stat/StatPanel.tsx | 206 ++++++------ public/locales/en-US/grafana.json | 3 + 22 files changed, 924 insertions(+), 1104 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2d84e04dadf..e068ca60eff 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -539,11 +539,6 @@ "count": 2 } }, - "packages/grafana-sql/src/components/ErrorBoundary.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx": { "no-restricted-syntax": { "count": 2 @@ -666,11 +661,6 @@ "count": 3 } }, - "packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "packages/grafana-ui/src/components/FormField/FormField.tsx": { "no-restricted-syntax": { "count": 1 @@ -1278,21 +1268,11 @@ "count": 1 } }, - "public/app/core/components/Select/ServiceAccountPicker.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/core/components/Select/TeamPicker.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 } }, - "public/app/core/components/Select/UserPicker.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/core/components/SharedPreferences/SharedPreferences.tsx": { "no-restricted-syntax": { "count": 8 @@ -1342,11 +1322,6 @@ "count": 2 } }, - "public/app/core/components/TimelineChart/TimelineChart.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/core/config.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -1483,25 +1458,17 @@ "public/app/features/admin/UserLdapSyncInfo.tsx": { "no-restricted-syntax": { "count": 3 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "public/app/features/admin/UserOrgs.tsx": { "no-restricted-syntax": { "count": 2 }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 3 - } - }, - "public/app/features/admin/UserProfile.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 } }, - "public/app/features/admin/UserSessions.tsx": { + "public/app/features/admin/UserProfile.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 } @@ -2399,9 +2366,6 @@ "public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx": { "no-restricted-syntax": { "count": 5 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx": { @@ -2752,11 +2716,6 @@ "count": 1 } }, - "public/app/features/datasources/components/DataSourcePluginSettings.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/datasources/components/DataSourcePluginState.tsx": { "no-restricted-syntax": { "count": 3 @@ -2872,11 +2831,6 @@ "count": 1 } }, - "public/app/features/explore/ExploreTimeControls.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/explore/Logs/LiveLogs.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 @@ -2977,11 +2931,6 @@ "count": 1 } }, - "public/app/features/explore/TraceView/components/common/SearchBarInput.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/explore/TraceView/components/demo/trace-generators.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -3118,11 +3067,6 @@ "count": 2 } }, - "public/app/features/live/LiveConnectionWarning.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/live/centrifuge/LiveDataStream.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -3578,11 +3522,6 @@ "count": 1 } }, - "public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -3700,11 +3639,6 @@ "count": 2 } }, - "public/app/features/variables/constant/ConstantVariableEditor.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/variables/constant/reducer.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -3787,9 +3721,6 @@ "public/app/features/variables/pickers/shared/VariableInput.tsx": { "no-restricted-syntax": { "count": 1 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 } }, "public/app/features/variables/pickers/shared/VariableOptions.tsx": { @@ -4004,11 +3935,6 @@ "count": 1 } }, - "public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx": { "no-restricted-syntax": { "count": 1 @@ -4207,11 +4133,6 @@ "count": 1 } }, - "public/app/plugins/datasource/grafana-testdata-datasource/components/CSVWaveEditor.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 2 @@ -4920,11 +4841,6 @@ "count": 1 } }, - "public/app/plugins/panel/stat/StatPanel.tsx": { - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/plugins/panel/state-timeline/migrations.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/packages/grafana-sql/src/components/ErrorBoundary.tsx b/packages/grafana-sql/src/components/ErrorBoundary.tsx index f7991415795..8500fb68fdf 100644 --- a/packages/grafana-sql/src/components/ErrorBoundary.tsx +++ b/packages/grafana-sql/src/components/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import { Component, ErrorInfo, PropsWithChildren } from 'react'; import { Trans } from '@grafana/i18n'; @@ -6,14 +6,14 @@ type Props = { fallBackComponent?: React.ReactNode; }; -export class ErrorBoundary extends React.Component, { hasError: boolean }> { - constructor(props: React.PropsWithChildren) { +export class ErrorBoundary extends Component, { hasError: boolean }> { + constructor(props: PropsWithChildren) { super(props); this.state = { hasError: false }; } - static getDerivedStateFromError() { - return { hasError: true }; + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ hasError: true }); } render() { diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx index dd49f34b985..818e919710c 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,6 +1,7 @@ -import { PureComponent, ReactNode, ComponentType, ErrorInfo } from 'react'; +import { PureComponent, ReactNode, ComponentType, ErrorInfo, memo } from 'react'; import { faro } from '@grafana/faro-web-sdk'; +import { t } from '@grafana/i18n'; import { Alert } from '../Alert/Alert'; @@ -116,15 +117,9 @@ export interface ErrorBoundaryAlertProps { errorLogger?: (error: Error) => void; } -export class ErrorBoundaryAlert extends PureComponent { - static defaultProps: Partial = { - title: 'An unexpected error happened', - style: 'alertbox', - }; - - render() { - const { title, children, style, dependencies, errorLogger, boundaryName } = this.props; - +export const ErrorBoundaryAlert = memo( + ({ title, children, style = 'alertbox', dependencies, errorLogger, boundaryName }: ErrorBoundaryAlertProps) => { + const alertTitle = title ?? t('grafana-ui.error-boundary.title', 'An unexpected error happened'); return ( {({ error, errorInfo }) => { @@ -134,7 +129,7 @@ export class ErrorBoundaryAlert extends PureComponent { if (style === 'alertbox') { return ( - +
{error && error.toString()}
@@ -144,12 +139,14 @@ export class ErrorBoundaryAlert extends PureComponent { ); } - return ; + return ; }} ); } -} +); + +ErrorBoundaryAlert.displayName = 'ErrorBoundaryAlert'; /** * HOC for wrapping a component in an error boundary. diff --git a/public/app/core/components/Select/ServiceAccountPicker.tsx b/public/app/core/components/Select/ServiceAccountPicker.tsx index 037404dcb2b..e9b200a4cb0 100644 --- a/public/app/core/components/Select/ServiceAccountPicker.tsx +++ b/public/app/core/components/Select/ServiceAccountPicker.tsx @@ -1,6 +1,6 @@ import debounce from 'debounce-promise'; import { isNil } from 'lodash'; -import { Component } from 'react'; +import { useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -14,66 +14,58 @@ export interface Props { inputId?: string; } -export interface State { - isLoading: boolean; -} +export const ServiceAccountPicker = ({ className, onSelected, inputId }: Props) => { + const [isLoading, setIsLoading] = useState(false); -export class ServiceAccountPicker extends Component { - constructor(props: Props) { - super(props); - this.state = { isLoading: false }; - } + const search = useMemo( + () => + debounce( + async (query?: string) => { + setIsLoading(true); - search = debounce( - async (query?: string) => { - this.setState({ isLoading: true }); + if (isNil(query)) { + query = ''; + } - if (isNil(query)) { - query = ''; - } - - return getBackendSrv() - .get(`/api/serviceaccounts/search?query=${query}&perpage=100`) - .then((result: ServiceAccountsState) => { - return result.serviceAccounts.map((sa) => ({ - id: sa.id, - uid: sa.uid, - value: sa, - label: sa.login, - imgUrl: sa.avatarUrl, - login: sa.login, - })); - }) - .finally(() => { - this.setState({ isLoading: false }); - }); - }, - 300, - { leading: true } + return getBackendSrv() + .get(`/api/serviceaccounts/search?query=${query}&perpage=100`) + .then((result: ServiceAccountsState) => { + return result.serviceAccounts.map((sa) => ({ + id: sa.id, + uid: sa.uid, + value: sa, + label: sa.login, + imgUrl: sa.avatarUrl, + login: sa.login, + })); + }) + .finally(() => { + setIsLoading(false); + }); + }, + 300, + { leading: true } + ), + [] ); - render() { - const { className, onSelected, inputId } = this.props; - const { isLoading } = this.state; - - return ( -
- -
- ); - } -} + return ( +
+ +
+ ); +}; diff --git a/public/app/core/components/Select/UserPicker.tsx b/public/app/core/components/Select/UserPicker.tsx index 5284cbeca5f..e7d4110d09b 100644 --- a/public/app/core/components/Select/UserPicker.tsx +++ b/public/app/core/components/Select/UserPicker.tsx @@ -1,6 +1,6 @@ import debounce from 'debounce-promise'; import { isNil } from 'lodash'; -import { Component } from 'react'; +import { useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -14,63 +14,55 @@ export interface Props { inputId?: string; } -export interface State { - isLoading: boolean; -} +export const UserPicker = ({ className, onSelected, inputId }: Props) => { + const [isLoading, setIsLoading] = useState(false); -export class UserPicker extends Component { - constructor(props: Props) { - super(props); - this.state = { isLoading: false }; - } + const search = useMemo( + () => + debounce( + async (query?: string) => { + setIsLoading(true); - search = debounce( - async (query?: string) => { - this.setState({ isLoading: true }); + if (isNil(query)) { + query = ''; + } - if (isNil(query)) { - query = ''; - } - - return getBackendSrv() - .get(`/api/org/users/lookup?query=${query}&limit=100`) - .then((result: OrgUser[]) => { - return result.map((user) => ({ - id: user.userId, - uid: user.uid, - value: user, - label: user.login, - imgUrl: user.avatarUrl, - login: user.login, - })); - }) - .finally(() => { - this.setState({ isLoading: false }); - }); - }, - 300, - { leading: true } + return getBackendSrv() + .get(`/api/org/users/lookup?query=${query}&limit=100`) + .then((result: OrgUser[]) => { + return result.map((user) => ({ + id: user.userId, + uid: user.uid, + value: user, + label: user.login, + imgUrl: user.avatarUrl, + login: user.login, + })); + }) + .finally(() => { + setIsLoading(false); + }); + }, + 300, + { leading: true } + ), + [] ); - render() { - const { className, onSelected, inputId } = this.props; - const { isLoading } = this.state; - - return ( -
- -
- ); - } -} + return ( +
+ +
+ ); +}; diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index f4317a127b7..ce6e4a1f4c1 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { useCallback } from 'react'; import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data'; import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema'; @@ -25,69 +25,76 @@ export interface TimelineProps extends Omit { - getValueColor = (frameIdx: number, fieldIdx: number, value: unknown) => { - const field = this.props.frames[frameIdx]?.fields[fieldIdx]; +export const TimelineChart = (props: TimelineProps) => { + const { frames, timeZone, rowHeight, tooltip, legend, legendItems } = props; - if (field?.display) { - const disp = field.display(value); // will apply color modes - if (disp.color) { - return disp.color; + const getValueColor = useCallback( + (frameIdx: number, fieldIdx: number, value: unknown) => { + const field = frames[frameIdx]?.fields[fieldIdx]; + + if (field?.display) { + const disp = field.display(value); // will apply color modes + if (disp.color) { + return disp.color; + } } - } - return FALLBACK_COLOR; - }; + return FALLBACK_COLOR; + }, + [frames] + ); - prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { - return preparePlotConfigBuilder({ - frame: alignedFrame, - getTimeRange, - allFrames: this.props.frames, - ...this.props, + const prepConfig = useCallback( + (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + return preparePlotConfigBuilder({ + frame: alignedFrame, + getTimeRange, + allFrames: frames, + ...props, - // Ensure timezones is passed as an array - timeZones: Array.isArray(this.props.timeZone) ? this.props.timeZone : [this.props.timeZone], + // Ensure timezones is passed as an array + timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], - // When there is only one row, use the full space - rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1, - getValueColor: this.getValueColor, + // When there is only one row, use the full space + rowHeight: alignedFrame.fields.length > 2 ? rowHeight : 1, + getValueColor: getValueColor, - hoverMulti: this.props.tooltip?.mode === TooltipDisplayMode.Multi, - }); - }; + hoverMulti: tooltip?.mode === TooltipDisplayMode.Multi, + }); + }, + [frames, props, timeZone, rowHeight, getValueColor, tooltip] + ); - renderLegend = (config: UPlotConfigBuilder) => { - const { legend, legendItems } = this.props; + const renderLegend = useCallback( + (config: UPlotConfigBuilder) => { + if (!config || !legendItems || !legend || legend.showLegend === false) { + return null; + } - if (!config || !legendItems || !legend || legend.showLegend === false) { - return null; - } + return ( + + + + ); + }, + [legend, legendItems] + ); - return ( - - - - ); - }; - - render() { - return ( - f.type === FieldType.time, - y: (f) => - f.type === FieldType.number || - f.type === FieldType.boolean || - f.type === FieldType.string || - f.type === FieldType.enum, - }} - prepConfig={this.prepConfig} - propsToDiff={propsToDiff} - renderLegend={this.renderLegend} - omitHideFromViz={true} - /> - ); - } -} + return ( + f.type === FieldType.time, + y: (f) => + f.type === FieldType.number || + f.type === FieldType.boolean || + f.type === FieldType.string || + f.type === FieldType.enum, + }} + prepConfig={prepConfig} + propsToDiff={propsToDiff} + renderLegend={renderLegend} + omitHideFromViz={true} + /> + ); +}; diff --git a/public/app/features/admin/UserLdapSyncInfo.tsx b/public/app/features/admin/UserLdapSyncInfo.tsx index 1baf9e219a2..4277fa4c814 100644 --- a/public/app/features/admin/UserLdapSyncInfo.tsx +++ b/public/app/features/admin/UserLdapSyncInfo.tsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { memo } from 'react'; import { dateTimeFormat } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; @@ -16,80 +16,72 @@ interface Props { onUserSync: () => void; } -interface State {} - const format = 'dddd YYYY-MM-DD HH:mm zz'; const debugLDAPMappingBaseURL = '/admin/authentication/ldap'; -export class UserLdapSyncInfo extends PureComponent { - onUserSync = () => { - this.props.onUserSync(); - }; +export const UserLdapSyncInfo = memo(({ ldapSyncInfo, user, onUserSync }: Props) => { + const nextSyncSuccessful = ldapSyncInfo && ldapSyncInfo.nextSync; + const nextSyncTime = nextSyncSuccessful ? dateTimeFormat(ldapSyncInfo.nextSync, { format }) : ''; + const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?username=${user && user.login}`; + const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead); + const canSyncLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersSync); - render() { - const { ldapSyncInfo, user } = this.props; - const nextSyncSuccessful = ldapSyncInfo && ldapSyncInfo.nextSync; - const nextSyncTime = nextSyncSuccessful ? dateTimeFormat(ldapSyncInfo.nextSync, { format }) : ''; - const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?username=${user && user.login}`; - const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead); - const canSyncLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersSync); - - return ( - <> -

- LDAP Synchronisation -

-
-
- - - - - - - - - - - - -
- External sync - - - User synced via LDAP. Some changes must be done in LDAP or mappings. - - - -
- Next scheduled synchronization - - {ldapSyncInfo.enabled ? ( - nextSyncTime - ) : ( - Not enabled - )} -
-
-
- {canSyncLDAPUser && ( - - )} - {canReadLDAPUser && ( - - Debug LDAP Mapping - - )} -
+ return ( + <> +

+ LDAP Synchronisation +

+
+
+ + + + + + + + + + + + +
+ External sync + + + User synced via LDAP. Some changes must be done in LDAP or mappings. + + + +
+ Next scheduled synchronization + + {ldapSyncInfo.enabled ? ( + nextSyncTime + ) : ( + Not enabled + )} +
- - ); - } -} +
+ {canSyncLDAPUser && ( + + )} + {canReadLDAPUser && ( + + Debug LDAP Mapping + + )} +
+
+ + ); +}); +UserLdapSyncInfo.displayName = 'UserLdapSyncInfo'; diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx index b7083bf7051..a0169a1022a 100644 --- a/public/app/features/admin/UserOrgs.tsx +++ b/public/app/features/admin/UserOrgs.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { createRef, PureComponent, ReactElement } from 'react'; +import { memo, PureComponent, ReactElement, useEffect, useRef, useState } from 'react'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; @@ -10,10 +10,8 @@ import { Icon, Modal, stylesFactory, - Themeable2, Tooltip, useStyles2, - withTheme2, Stack, TextLink, } from '@grafana/ui'; @@ -37,73 +35,63 @@ interface Props { onOrgAdd: (orgId: number, role: OrgRole) => void; } -interface State { - showAddOrgModal: boolean; -} +export const UserOrgs = memo(({ user, orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd }: Props) => { + const [showAddOrgModal, setShowAddOrgModal] = useState(false); + const addToOrgButtonRef = useRef(null); -export class UserOrgs extends PureComponent { - addToOrgButtonRef = createRef(); - state = { - showAddOrgModal: false, + const showOrgAddModal = () => { + setShowAddOrgModal(true); }; - showOrgAddModal = () => { - this.setState({ showAddOrgModal: true }); + const dismissOrgAddModal = () => { + setShowAddOrgModal(false); + addToOrgButtonRef.current?.focus(); }; - dismissOrgAddModal = () => { - this.setState({ showAddOrgModal: false }, () => { - this.addToOrgButtonRef.current?.focus(); - }); - }; + const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd) && !isExternalUser; - render() { - const { user, orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props; - const { showAddOrgModal } = this.state; + return ( +
+

+ Organizations +

+ + + + {orgs.map((org, index) => ( + + ))} + +
- const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd) && !isExternalUser; - return ( -
-

- Organizations -

- - - - {orgs.map((org, index) => ( - - ))} - -
+
+ {canAddToOrg && ( + + )} +
+ +
+
+ ); +}); +UserOrgs.displayName = 'UserOrgs'; -
- {canAddToOrg && ( - - )} -
- -
-
- ); - } -} - -const getOrgRowStyles = stylesFactory((theme: GrafanaTheme2) => { +const getOrgRowStyles = (theme: GrafanaTheme2) => { return { removeButton: css({ marginRight: '0.6rem', @@ -130,9 +118,9 @@ const getOrgRowStyles = stylesFactory((theme: GrafanaTheme2) => { marginRight: theme.spacing(1), }), }; -}); +}; -interface OrgRowProps extends Themeable2 { +interface OrgRowProps { user?: UserDTO; org: UserOrg; isExternalUser?: boolean; @@ -140,124 +128,116 @@ interface OrgRowProps extends Themeable2 { onOrgRoleChange: (orgId: number, newRole: OrgRole) => void; } -class UnThemedOrgRow extends PureComponent { - state = { - currentRole: this.props.org.role, - isChangingRole: false, - roleOptions: [], - }; +const OrgRow = memo(({ user, org, isExternalUser, onOrgRemove, onOrgRoleChange }: OrgRowProps) => { + const [currentRole, setCurrentRole] = useState(org.role); + const [isChangingRole, setIsChangingRole] = useState(false); + const [roleOptions, setRoleOptions] = useState([]); + const styles = useStyles2(getOrgRowStyles); - componentDidMount() { + useEffect(() => { if (contextSrv.licensedAccessControlEnabled()) { if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { - fetchRoleOptions(this.props.org.orgId) - .then((roles) => this.setState({ roleOptions: roles })) + fetchRoleOptions(org.orgId) + .then((roles) => setRoleOptions(roles)) .catch((e) => console.error(e)); } } - } + }, [org.orgId]); - onOrgRemove = async () => { - const { org } = this.props; - this.props.onOrgRemove(org.orgId); + const handleOrgRemove = async () => { + onOrgRemove(org.orgId); }; - onChangeRoleClick = () => { - const { org } = this.props; - this.setState({ isChangingRole: true, currentRole: org.role }); + const handleChangeRoleClick = () => { + setIsChangingRole(true); + setCurrentRole(org.role); }; - onOrgRoleChange = (newRole: OrgRole) => { - this.setState({ currentRole: newRole }); + const handleOrgRoleChange = (newRole: OrgRole) => { + setCurrentRole(newRole); }; - onOrgRoleSave = () => { - this.props.onOrgRoleChange(this.props.org.orgId, this.state.currentRole); + const handleOrgRoleSave = () => { + onOrgRoleChange(org.orgId, currentRole); }; - onCancelClick = () => { - this.setState({ isChangingRole: false }); + const handleCancelClick = () => { + setIsChangingRole(false); }; - onBasicRoleChange = (newRole: OrgRole) => { - this.props.onOrgRoleChange(this.props.org.orgId, newRole); + const handleBasicRoleChange = (newRole: OrgRole) => { + onOrgRoleChange(org.orgId, newRole); }; - render() { - const { user, org, isExternalUser, theme } = this.props; - const authSource = user?.authLabels?.length && user?.authLabels[0]; - const lockMessage = authSource ? `Synced via ${authSource}` : ''; - const { currentRole, isChangingRole } = this.state; - const styles = getOrgRowStyles(theme); - const labelClass = cx('width-16', styles.label); - const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersWrite); - const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove) && !isExternalUser; - const rolePickerDisabled = isExternalUser || !canChangeRole; + const authSource = user?.authLabels?.length && user?.authLabels[0]; + const lockMessage = authSource ? `Synced via ${authSource}` : ''; + const labelClass = cx('width-16', styles.label); + const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersWrite); + const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove) && !isExternalUser; + const rolePickerDisabled = isExternalUser || !canChangeRole; - const inputId = `${org.name}-input`; - return ( - - - - - {contextSrv.licensedAccessControlEnabled() ? ( - -
-
- -
- {isExternalUser && } + const inputId = `${org.name}-input`; + return ( + + + + + {contextSrv.licensedAccessControlEnabled() ? ( + +
+
+
- - ) : ( - <> - {isChangingRole ? ( - - - - ) : ( - {org.role} - )} - - {canChangeRole && ( - - )} - - - )} - - {canRemoveFromOrg && ( - - {t('admin.user-orgs.remove-button', 'Remove from organization')} - - )} + {isExternalUser && } +
- - ); - } -} - -const OrgRow = withTheme2(UnThemedOrgRow); + ) : ( + <> + {isChangingRole ? ( + + + + ) : ( + {org.role} + )} + + {canChangeRole && ( + + )} + + + )} + + {canRemoveFromOrg && ( + + {t('admin.user-orgs.remove-button', 'Remove from organization')} + + )} + + + ); +}); +OrgRow.displayName = 'OrgRow'; const getAddToOrgModalStyles = stylesFactory(() => ({ modal: css({ diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx index f05278d7884..16fadaa8313 100644 --- a/public/app/features/admin/UserSessions.tsx +++ b/public/app/features/admin/UserSessions.tsx @@ -1,4 +1,4 @@ -import { createRef, PureComponent } from 'react'; +import { memo, useRef, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { ConfirmButton, ConfirmModal, Button, Stack } from '@grafana/ui'; @@ -15,120 +15,107 @@ interface Props { onAllSessionsRevoke: () => void; } -interface State { - showLogoutModal: boolean; -} +export const UserSessions = memo(({ sessions, onSessionRevoke, onAllSessionsRevoke }: Props) => { + const [showLogoutModal, setShowLogoutModal] = useState(false); + const forceAllLogoutButton = useRef(null); -class BaseUserSessions extends PureComponent { - forceAllLogoutButton = createRef(); - state: State = { - showLogoutModal: false, + const showLogoutConfirmationModal = () => { + setShowLogoutModal(true); }; - showLogoutConfirmationModal = () => { - this.setState({ showLogoutModal: true }); + const dismissLogoutConfirmationModal = () => { + setShowLogoutModal(false); + forceAllLogoutButton.current?.focus(); }; - dismissLogoutConfirmationModal = () => { - this.setState({ showLogoutModal: false }, () => { - this.forceAllLogoutButton.current?.focus(); - }); - }; - - onSessionRevoke = (id: number) => { + const handleSessionRevoke = (id: number) => { return () => { - this.props.onSessionRevoke(id); + onSessionRevoke(id); }; }; - onAllSessionsRevoke = () => { - this.setState({ showLogoutModal: false }); - this.props.onAllSessionsRevoke(); + const handleAllSessionsRevoke = () => { + setShowLogoutModal(false); + onAllSessionsRevoke(); }; - render() { - const { sessions } = this.props; - const { showLogoutModal } = this.state; + const canLogout = contextSrv.hasPermission(AccessControlAction.UsersLogout); - const canLogout = contextSrv.hasPermission(AccessControlAction.UsersLogout); + return ( +
+

+ Sessions +

+ +
+ + + + + + + + + + + + {sessions && + sessions.map((session, index) => ( + + + + + + + + + ))} + +
+ Last seen + + Logged on + + IP address + + Browser and OS + + Identity Provider +
{session.isActive ? t('admin.user-sessions.now', 'Now') : session.seenAt}{formatDate(session.createdAt, { dateStyle: 'long' })}{session.clientIp}{`${session.browser} on ${session.os} ${session.osVersion}`} + {session.authModule && } + + {canLogout && ( + + {t('admin.user-sessions.force-logout-button', 'Force logout')} + + )} +
+
- return ( -
-

- Sessions -

- -
- - - - - - - - - - - - {sessions && - sessions.map((session, index) => ( - - - - - - - - - ))} - -
- Last seen - - Logged on - - IP address - - Browser and OS - - Identity Provider -
{session.isActive ? t('admin.user-sessions.now', 'Now') : session.seenAt}{formatDate(session.createdAt, { dateStyle: 'long' })}{session.clientIp}{`${session.browser} on ${session.os} ${session.osVersion}`} - {session.authModule && } - - {canLogout && ( - - {t('admin.user-sessions.force-logout-button', 'Force logout')} - - )} -
-
- -
- {canLogout && sessions.length > 0 && ( - +
+ {canLogout && sessions.length > 0 && ( + + )} + -
- -
- ); - } -} - -export const UserSessions = BaseUserSessions; + confirmText={t('admin.base-user-sessions.confirmText-force-logout', 'Force logout')} + onConfirm={handleAllSessionsRevoke} + onDismiss={dismissLogoutConfirmationModal} + /> +
+
+
+ ); +}); +UserSessions.displayName = 'UserSessions'; diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx index bfcd9a5e66d..ba88db1e1bf 100644 --- a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx @@ -1,6 +1,5 @@ import { isEmpty } from 'lodash'; -import { PureComponent } from 'react'; -import * as React from 'react'; +import { FormEvent, memo, useState } from 'react'; import { rangeUtil, TimeZone } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -24,49 +23,58 @@ interface Props { liveNow?: boolean; } -interface State { - isNowDelayValid: boolean; -} +export const TimePickerSettings = memo( + ({ + onWeekStartChange, + onTimeZoneChange, + onRefreshIntervalChange, + onNowDelayChange, + onHideTimePickerChange, + onLiveNowChange, + refreshIntervals, + timePickerHidden, + nowDelay, + timezone, + weekStart, + liveNow, + }: Props) => { + const [isNowDelayValid, setIsNowDelayValid] = useState(true); -export class TimePickerSettings extends PureComponent { - state: State = { isNowDelayValid: true }; + const handleNowDelayChange = (event: FormEvent) => { + const value = event.currentTarget.value; - onNowDelayChange = (event: React.FormEvent) => { - const value = event.currentTarget.value; + if (isEmpty(value)) { + setIsNowDelayValid(true); + return onNowDelayChange(value); + } - if (isEmpty(value)) { - this.setState({ isNowDelayValid: true }); - return this.props.onNowDelayChange(value); - } + if (rangeUtil.isValidTimeSpan(value)) { + setIsNowDelayValid(true); + return onNowDelayChange(value); + } - if (rangeUtil.isValidTimeSpan(value)) { - this.setState({ isNowDelayValid: true }); - return this.props.onNowDelayChange(value); - } + setIsNowDelayValid(false); + }; - this.setState({ isNowDelayValid: false }); - }; + const handleHideTimePickerChange = () => { + onHideTimePickerChange(!timePickerHidden); + }; - onHideTimePickerChange = () => { - this.props.onHideTimePickerChange(!this.props.timePickerHidden); - }; + const handleLiveNowChange = () => { + onLiveNowChange(!liveNow); + }; - onLiveNowChange = () => { - this.props.onLiveNowChange(!this.props.liveNow); - }; + const handleTimeZoneChange = (timeZone?: string) => { + if (typeof timeZone !== 'string') { + return; + } + onTimeZoneChange(timeZone); + }; - onTimeZoneChange = (timeZone?: string) => { - if (typeof timeZone !== 'string') { - return; - } - this.props.onTimeZoneChange(timeZone); - }; + const handleWeekStartChange = (weekStart?: WeekStart) => { + onWeekStartChange(weekStart); + }; - onWeekStartChange = (weekStart?: WeekStart) => { - this.props.onWeekStartChange(weekStart); - }; - - render() { return ( { @@ -85,17 +93,9 @@ export class TimePickerSettings extends PureComponent { label={t('dashboard-settings.time-picker.week-start-label', 'Week start')} data-testid={selectors.components.WeekStartPicker.containerV2} > - + - + { > - + { 'Continuously update panels when the time range includes the current time' )} > - + ); } -} +); +TimePickerSettings.displayName = 'TimePickerSettings'; diff --git a/public/app/features/datasources/components/DataSourcePluginSettings.tsx b/public/app/features/datasources/components/DataSourcePluginSettings.tsx index f920837275b..556896147fe 100644 --- a/public/app/features/datasources/components/DataSourcePluginSettings.tsx +++ b/public/app/features/datasources/components/DataSourcePluginSettings.tsx @@ -1,4 +1,4 @@ -import { createElement, PureComponent } from 'react'; +import { createElement, memo } from 'react'; import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data'; import { writableProxy } from 'app/features/plugins/extensions/utils'; @@ -12,36 +12,23 @@ export interface Props { onModelChange: (dataSource: DataSourceSettings) => void; } -export class DataSourcePluginSettings extends PureComponent { - constructor(props: Props) { - super(props); - - this.onModelChanged = this.onModelChanged.bind(this); +export const DataSourcePluginSettings = memo(({ plugin, dataSource, onModelChange }: Props) => { + if (!plugin) { + return null; } - onModelChanged = (dataSource: DataSourceSettings) => { - this.props.onModelChange(dataSource); - }; - - render() { - const { plugin, dataSource } = this.props; - - if (!plugin) { - return null; - } - - return ( -
- {plugin.components.ConfigEditor && - createElement(plugin.components.ConfigEditor, { - options: writableProxy(dataSource, { - source: 'datasource', - pluginId: plugin.meta?.id, - pluginVersion: plugin.meta?.info?.version, - }), - onOptionsChange: this.onModelChanged, - })} -
- ); - } -} + return ( +
+ {plugin.components.ConfigEditor && + createElement(plugin.components.ConfigEditor, { + options: writableProxy(dataSource, { + source: 'datasource', + pluginId: plugin.meta?.id, + pluginVersion: plugin.meta?.info?.version, + }), + onOptionsChange: onModelChange, + })} +
+ ); +}); +DataSourcePluginSettings.displayName = 'DataSourcePluginSettings'; diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index 07c92e8fba5..ef616131b6e 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -1,5 +1,3 @@ -import { Component } from 'react'; - import { TimeRange, RawTimeRange, dateTimeForTimeZone, dateMath } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; @@ -22,9 +20,19 @@ export interface Props { onChangeFiscalYearStartMonth: (fiscalYearStartMonth: number) => void; } -export class ExploreTimeControls extends Component { - onMoveTimePicker = (direction: number) => { - const { range, onChangeTime, timeZone } = this.props; +export const ExploreTimeControls = ({ + range, + timeZone, + fiscalYearStartMonth, + splitted, + syncedTimes, + onChangeTimeSync, + hideText, + onChangeTimeZone, + onChangeFiscalYearStartMonth, + onChangeTime, +}: Props) => { + const onMoveTimePicker = (direction: number) => { const { from, to } = getShiftedTimeRange(direction, range); const nextTimeRange = { from: dateTimeForTimeZone(timeZone, from), @@ -34,14 +42,14 @@ export class ExploreTimeControls extends Component { onChangeTime(nextTimeRange); }; - onMoveForward = () => this.onMoveTimePicker(1); - onMoveBack = () => this.onMoveTimePicker(-1); + const onMoveForward = () => onMoveTimePicker(1); + const onMoveBack = () => onMoveTimePicker(-1); - onChangeTimePicker = (timeRange: TimeRange) => { + const onChangeTimePicker = (timeRange: TimeRange) => { const adjustedFrom = dateMath.isMathString(timeRange.raw.from) ? timeRange.raw.from : timeRange.from; const adjustedTo = dateMath.isMathString(timeRange.raw.to) ? timeRange.raw.to : timeRange.to; - this.props.onChangeTime({ + onChangeTime({ from: adjustedFrom, to: adjustedTo, }); @@ -52,8 +60,7 @@ export class ExploreTimeControls extends Component { }); }; - onZoom = () => { - const { range, onChangeTime, timeZone } = this.props; + const onZoom = () => { const { from, to } = getZoomedTimeRange(range, 2); const nextTimeRange = { from: dateTimeForTimeZone(timeZone, from), @@ -63,40 +70,27 @@ export class ExploreTimeControls extends Component { onChangeTime(nextTimeRange); }; - render() { - const { - range, - timeZone, - fiscalYearStartMonth, - splitted, - syncedTimes, - onChangeTimeSync, - hideText, - onChangeTimeZone, - onChangeFiscalYearStartMonth, - } = this.props; - const timeSyncButton = splitted ? : undefined; - const timePickerCommonProps = { - value: range, - timeZone, - fiscalYearStartMonth, - onMoveBackward: this.onMoveBack, - onMoveForward: this.onMoveForward, - onZoom: this.onZoom, - hideText, - }; + const timeSyncButton = splitted ? : undefined; + const timePickerCommonProps = { + value: range, + timeZone, + fiscalYearStartMonth, + onMoveBackward: onMoveBack, + onMoveForward: onMoveForward, + onZoom: onZoom, + hideText, + }; - return ( - - ); - } -} + return ( + + ); +}; diff --git a/public/app/features/explore/TraceView/components/common/SearchBarInput.test.tsx b/public/app/features/explore/TraceView/components/common/SearchBarInput.test.tsx index e613c07fcfc..6e7b590a016 100644 --- a/public/app/features/explore/TraceView/components/common/SearchBarInput.test.tsx +++ b/public/app/features/explore/TraceView/components/common/SearchBarInput.test.tsx @@ -18,15 +18,16 @@ import SearchBarInput from './SearchBarInput'; describe('SearchBarInput', () => { describe('rendering', () => { + const onChange = jest.fn(); it('renders as expected with no value', () => { - render(); + render(); const searchBarInput = screen.queryByPlaceholderText('Find...'); expect(searchBarInput).toBeInTheDocument(); expect(searchBarInput?.getAttribute('value')).toEqual(''); }); it('renders as expected with value', () => { - render(); + render(); const searchBarInput = screen.queryByPlaceholderText('Find...'); expect(searchBarInput).toBeInTheDocument(); expect(searchBarInput?.getAttribute('value')).toEqual('value'); diff --git a/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx b/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx index 58db71fd862..a6527ca1c0e 100644 --- a/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx +++ b/public/app/features/explore/TraceView/components/common/SearchBarInput.tsx @@ -12,49 +12,44 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as React from 'react'; +import { memo } from 'react'; import { t } from '@grafana/i18n'; import { IconButton, Input } from '@grafana/ui'; type Props = { - value: string | undefined; + value?: string; onChange: (value: string) => void; }; -export default class SearchBarInput extends React.PureComponent { - static defaultProps: Partial = { - value: undefined, +const SearchBarInput = memo(({ value, onChange }: Props) => { + const clearUiFind = () => { + onChange(''); }; - clearUiFind = () => { - this.props.onChange(''); - }; - - render() { - const { value } = this.props; - - const suffix = ( - <> - {value && value.length && ( - - )} - - ); - - return ( -
- this.props.onChange(e.currentTarget.value)} - suffix={suffix} - value={value} + const suffix = ( + <> + {value && value.length && ( + -
- ); - } -} + )} + + ); + + return ( +
+ onChange(e.currentTarget.value)} + suffix={suffix} + value={value} + /> +
+ ); +}); +SearchBarInput.displayName = 'SearchBarInput'; + +export default SearchBarInput; diff --git a/public/app/features/live/LiveConnectionWarning.tsx b/public/app/features/live/LiveConnectionWarning.tsx index 27595f0b908..2f78ebd22db 100644 --- a/public/app/features/live/LiveConnectionWarning.tsx +++ b/public/app/features/live/LiveConnectionWarning.tsx @@ -1,72 +1,63 @@ import { css } from '@emotion/css'; -import { PureComponent } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { Unsubscribable } from 'rxjs'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, getGrafanaLiveSrv } from '@grafana/runtime'; -import { Alert, stylesFactory } from '@grafana/ui'; +import { Alert, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; export interface Props {} -export interface State { - show?: boolean; -} +export const LiveConnectionWarning = memo(function LiveConnectionWarning() { + const [show, setShow] = useState(undefined); + const subscriptionRef = useRef(); + const styles = useStyles2(getStyle); -export class LiveConnectionWarning extends PureComponent { - subscription?: Unsubscribable; - styles = getStyle(config.theme2); - state: State = {}; - - componentDidMount() { + useEffect(() => { // Only show the error in development mode if (process.env.NODE_ENV === 'development') { // Wait a second to listen for server errors - setTimeout(this.initListener, 1500); + const timer = setTimeout(() => { + const live = getGrafanaLiveSrv(); + if (live) { + subscriptionRef.current = live.getConnectionState().subscribe({ + next: (v) => { + setShow(!v); + }, + }); + } + }, 1500); + + return () => { + clearTimeout(timer); + if (subscriptionRef.current) { + subscriptionRef.current.unsubscribe(); + } + }; } + + return undefined; + }, []); + + if (show) { + if (!contextSrv.isSignedIn || !config.liveEnabled || contextSrv.user.orgRole === OrgRole.None) { + return null; // do not show the warning for anonymous users or ones with no org (and /login page etc) + } + + return ( + + ); } + return null; +}); - initListener = () => { - const live = getGrafanaLiveSrv(); - if (live) { - this.subscription = live.getConnectionState().subscribe({ - next: (v) => { - this.setState({ show: !v }); - }, - }); - } - }; - - componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - - render() { - const { show } = this.state; - if (show) { - if (!contextSrv.isSignedIn || !config.liveEnabled || contextSrv.user.orgRole === OrgRole.None) { - return null; // do not show the warning for anonymous users or ones with no org (and /login page etc) - } - - return ( - - ); - } - return null; - } -} - -const getStyle = stylesFactory((theme: GrafanaTheme2) => ({ +const getStyle = (theme: GrafanaTheme2) => ({ warn: css({ position: 'fixed', bottom: 0, @@ -76,4 +67,4 @@ const getStyle = stylesFactory((theme: GrafanaTheme2) => ({ zIndex: theme.zIndex.portal, cursor: 'wait', }), -})); +}); diff --git a/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx b/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx index e31ea83a565..91bcb2c1542 100644 --- a/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx @@ -1,4 +1,4 @@ -import { PureComponent, ChangeEvent } from 'react'; +import { memo, ChangeEvent } from 'react'; import { DataTransformerID, @@ -18,13 +18,8 @@ import lightImage from '../images/light/concatenate.svg'; interface ConcatenateTransformerEditorProps extends TransformerUIProps {} -export class ConcatenateTransformerEditor extends PureComponent { - constructor(props: ConcatenateTransformerEditorProps) { - super(props); - } - - onModeChanged = (value: SelectableValue) => { - const { options, onChange } = this.props; +export const ConcatenateTransformerEditor = memo(({ options, onChange }: ConcatenateTransformerEditorProps) => { + const onModeChanged = (value: SelectableValue) => { const frameNameMode = value.value ?? ConcatenateFrameNameMode.FieldName; onChange({ ...options, @@ -32,74 +27,63 @@ export class ConcatenateTransformerEditor extends PureComponent) => { - const { options } = this.props; - this.props.onChange({ + const onLabelChanged = (evt: ChangeEvent) => { + onChange({ ...options, frameNameLabel: evt.target.value, }); }; - //--------------------------------------------------------- - // Render - //--------------------------------------------------------- + const nameModes: Array> = [ + { + value: ConcatenateFrameNameMode.FieldName, + label: t( + 'transformers.concatenate-transformer-editor.name-modes.label.copy-frame-name-to-field', + 'Copy frame name to field name' + ), + }, + { + value: ConcatenateFrameNameMode.Label, + label: t( + 'transformers.concatenate-transformer-editor.name-modes.label.label-frame', + 'Add a label with the frame name' + ), + }, + { + value: ConcatenateFrameNameMode.Drop, + label: t( + 'transformers.concatenate-transformer-editor.name-modes.label.ignore-the-frame-name', + 'Ignore the frame name' + ), + }, + ]; - render() { - const { options } = this.props; - const nameModes: Array> = [ - { - value: ConcatenateFrameNameMode.FieldName, - label: t( - 'transformers.concatenate-transformer-editor.name-modes.label.copy-frame-name-to-field', - 'Copy frame name to field name' - ), - }, - { - value: ConcatenateFrameNameMode.Label, - label: t( - 'transformers.concatenate-transformer-editor.name-modes.label.label-frame', - 'Add a label with the frame name' - ), - }, - { - value: ConcatenateFrameNameMode.Drop, - label: t( - 'transformers.concatenate-transformer-editor.name-modes.label.ignore-the-frame-name', - 'Ignore the frame name' - ), - }, - ]; + const frameNameMode = options.frameNameMode ?? ConcatenateFrameNameMode.FieldName; - const frameNameMode = options.frameNameMode ?? ConcatenateFrameNameMode.FieldName; - - return ( -
- - v.value === frameNameMode)} + onChange={onModeChanged} + /> + + {frameNameMode === ConcatenateFrameNameMode.Label && ( + + v.value === frameNameMode)} - onChange={this.onModeChanged} + value={options.frameNameLabel ?? ''} + placeholder={t('transformers.concatenate-transformer-editor.placeholder-frame', 'Frame')} + onChange={onLabelChanged} /> - {frameNameMode === ConcatenateFrameNameMode.Label && ( - - - - )} -
- ); - } -} + )} +
+ ); +}); +ConcatenateTransformerEditor.displayName = 'ConcatenateTransformerEditor'; export const getConcatenateTransformRegistryItem: () => TransformerRegistryItem = () => ({ diff --git a/public/app/features/variables/constant/ConstantVariableEditor.tsx b/public/app/features/variables/constant/ConstantVariableEditor.tsx index 6325ef2890c..5eb7a1f352d 100644 --- a/public/app/features/variables/constant/ConstantVariableEditor.tsx +++ b/public/app/features/variables/constant/ConstantVariableEditor.tsx @@ -1,4 +1,4 @@ -import { FormEvent, PureComponent } from 'react'; +import { FormEvent, memo } from 'react'; import { ConstantVariableModel } from '@grafana/data'; import { ConstantVariableForm } from 'app/features/dashboard-scene/settings/variables/components/ConstantVariableForm'; @@ -7,16 +7,15 @@ import { VariableEditorProps } from '../editor/types'; export interface Props extends VariableEditorProps {} -export class ConstantVariableEditor extends PureComponent { - onChange = (event: FormEvent) => { - this.props.onPropChange({ +export const ConstantVariableEditor = memo(({ variable, onPropChange }: Props) => { + const onChange = (event: FormEvent) => { + onPropChange({ propName: 'query', propValue: event.currentTarget.value, updateOptions: true, }); }; - render() { - return ; - } -} + return ; +}); +ConstantVariableEditor.displayName = 'ConstantVariableEditor'; diff --git a/public/app/features/variables/pickers/shared/VariableInput.tsx b/public/app/features/variables/pickers/shared/VariableInput.tsx index 7ff43d04657..8f577ac5da5 100644 --- a/public/app/features/variables/pickers/shared/VariableInput.tsx +++ b/public/app/features/variables/pickers/shared/VariableInput.tsx @@ -1,48 +1,45 @@ -import { PureComponent } from 'react'; -import * as React from 'react'; +import { memo, type KeyboardEvent, type HTMLProps } from 'react'; import { t } from '@grafana/i18n'; import { NavigationKey } from '../types'; -export interface Props extends Omit, 'onChange' | 'value'> { +export interface Props extends Omit, 'onChange' | 'value'> { onChange: (value: string) => void; onNavigate: (key: NavigationKey, clearOthers: boolean) => void; value: string | null; } -export class VariableInput extends PureComponent { - onKeyDown = (event: React.KeyboardEvent) => { +export const VariableInput = memo(({ value, id, onNavigate, onChange, ...restProps }: Props) => { + const onKeyDown = (event: KeyboardEvent) => { if (NavigationKey[event.keyCode] && event.keyCode !== NavigationKey.select) { const clearOthers = event.ctrlKey || event.metaKey || event.shiftKey; - this.props.onNavigate(event.keyCode, clearOthers); + onNavigate(event.keyCode, clearOthers); event.preventDefault(); } }; - onChange = (event: React.ChangeEvent) => { - this.props.onChange(event.target.value); + const handleChange = (event: React.ChangeEvent) => { + onChange(event.target.value); }; - render() { - const { value, id, onNavigate, ...restProps } = this.props; - return ( - { - if (instance) { - instance.focus(); - instance.setAttribute('style', `width:${Math.max(instance.width, 150)}px`); - } - }} - id={id} - type="text" - className="gf-form-input" - value={value ?? ''} - onChange={this.onChange} - onKeyDown={this.onKeyDown} - placeholder={t('variable.dropdown.placeholder', 'Enter variable value')} - /> - ); - } -} + return ( + { + if (instance) { + instance.focus(); + instance.setAttribute('style', `width:${Math.max(instance.width, 150)}px`); + } + }} + id={id} + type="text" + className="gf-form-input" + value={value ?? ''} + onChange={handleChange} + onKeyDown={onKeyDown} + placeholder={t('variable.dropdown.placeholder', 'Enter variable value')} + /> + ); +}); +VariableInput.displayName = 'VariableInput'; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx index 9889693efae..d866ad49185 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { memo } from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { ConnectionConfig } from '@grafana/google-sdk'; @@ -10,8 +10,8 @@ import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../typ export type Props = DataSourcePluginOptionsEditorProps; -export class ConfigEditor extends PureComponent { - handleOnOptionsChange = (options: Props['options']) => { +export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => { + const handleOnOptionsChange = (options: Props['options']) => { if (options.jsonData.privateKeyPath || options.secureJsonFields['privateKey']) { reportInteraction('grafana_cloud_monitoring_config_changed', { authenticationType: 'JWT', @@ -19,34 +19,32 @@ export class ConfigEditor extends PureComponent { privateKeyPath: !!options.jsonData.privateKeyPath, }); } - this.props.onOptionsChange(options); + onOptionsChange(options); }; - render() { - const { options, onOptionsChange } = this.props; - return ( - <> - - - - {config.secureSocksDSProxyEnabled && ( - <> - - - - - - )} - - ); - } -} + return ( + <> + + + + {config.secureSocksDSProxyEnabled && ( + <> + + + + + + )} + + ); +}); +ConfigEditor.displayName = 'ConfigEditor'; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/CSVWaveEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/CSVWaveEditor.tsx index ac2a9a2a779..1643cc0e9bd 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/CSVWaveEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/CSVWaveEditor.tsx @@ -1,4 +1,4 @@ -import { PureComponent, useState } from 'react'; +import { memo, useState } from 'react'; import * as React from 'react'; import { Button, InlineField, InlineFieldRow, Input } from '@grafana/ui'; @@ -93,43 +93,42 @@ const CSVWaveEditor = (props: WaveProps) => { ); }; -export class CSVWavesEditor extends PureComponent { - onChange = (index: number, wave?: CSVWave) => { - let waves = [...(this.props.waves ?? defaultCSVWaveQuery)]; +export const CSVWavesEditor = memo(({ waves, onChange }: WavesProps) => { + const handleChange = (index: number, wave?: CSVWave) => { + let wavesArray = [...(waves ?? defaultCSVWaveQuery)]; if (wave) { - waves[index] = { ...wave }; + wavesArray[index] = { ...wave }; } else { // remove the element - waves.splice(index, 1); + wavesArray.splice(index, 1); } - this.props.onChange(waves); + onChange(wavesArray); }; - onAdd = () => { - const waves = [...(this.props.waves ?? defaultCSVWaveQuery)]; - waves.push({ ...defaultCSVWaveQuery[0] }); - this.props.onChange(waves); + const onAdd = () => { + const wavesArray = [...(waves ?? defaultCSVWaveQuery)]; + wavesArray.push({ ...defaultCSVWaveQuery[0] }); + onChange(wavesArray); }; - render() { - let waves = this.props.waves ?? defaultCSVWaveQuery; - if (!waves.length) { - waves = defaultCSVWaveQuery; - } - - return ( - <> - {waves.map((wave, index) => ( - - ))} - - ); + let wavesArray = waves ?? defaultCSVWaveQuery; + if (!wavesArray.length) { + wavesArray = defaultCSVWaveQuery; } -} + + return ( + <> + {wavesArray.map((wave, index) => ( + + ))} + + ); +}); +CSVWavesEditor.displayName = 'CSVWavesEditor'; diff --git a/public/app/plugins/panel/stat/StatPanel.tsx b/public/app/plugins/panel/stat/StatPanel.tsx index 6d1c35cc63a..055fc4adc93 100644 --- a/public/app/plugins/panel/stat/StatPanel.tsx +++ b/public/app/plugins/panel/stat/StatPanel.tsx @@ -1,5 +1,5 @@ import { isNumber } from 'lodash'; -import { PureComponent } from 'react'; +import { memo, useCallback } from 'react'; import { DisplayValueAlignmentFactors, @@ -12,120 +12,131 @@ import { } from '@grafana/data'; import { findNumericFieldMinMax } from '@grafana/data/internal'; import { BigValueTextMode, BigValueGraphMode } from '@grafana/schema'; -import { BigValue, DataLinksContextMenu, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; +import { BigValue, DataLinksContextMenu, useTheme2, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui'; import { DataLinksContextMenuApi } from '@grafana/ui/internal'; -import { config } from 'app/core/config'; import { Options } from './panelcfg.gen'; -export class StatPanel extends PureComponent> { - renderComponent = ( - valueProps: VizRepeaterRenderValueProps, - menuProps: DataLinksContextMenuApi - ): JSX.Element => { - const { timeRange, options } = this.props; - const { value, alignmentFactors, width, height, count } = valueProps; - const { openMenu, targetClassName } = menuProps; - let sparkline = value.sparkline; - if (sparkline) { - sparkline.timeRange = timeRange; - } +export const StatPanel = memo( + ({ + timeRange, + options, + fieldConfig, + title, + data, + replaceVariables, + timeZone, + height, + width, + renderCounter, + }: PanelProps) => { + const theme = useTheme2(); - return ( - + const getTextMode = useCallback(() => { + // If we have manually set displayName or panel title switch text mode to value and name + if (options.textMode === BigValueTextMode.Auto && (fieldConfig.defaults.displayName || !title)) { + return BigValueTextMode.ValueAndName; + } + + return options.textMode; + }, [options.textMode, fieldConfig.defaults.displayName, title]); + + const renderComponent = useCallback( + ( + valueProps: VizRepeaterRenderValueProps, + menuProps: DataLinksContextMenuApi + ): JSX.Element => { + const { value, alignmentFactors, width, height, count } = valueProps; + const { openMenu, targetClassName } = menuProps; + let sparkline = value.sparkline; + if (sparkline) { + sparkline.timeRange = timeRange; + } + + return ( + + ); + }, + [theme, timeRange, options, getTextMode] ); - }; - getTextMode() { - const { options, fieldConfig, title } = this.props; + const renderValue = useCallback( + (valueProps: VizRepeaterRenderValueProps): JSX.Element => { + const { value } = valueProps; + const { getLinks, hasLinks } = value; - // If we have manually set displayName or panel title switch text mode to value and name - if (options.textMode === BigValueTextMode.Auto && (fieldConfig.defaults.displayName || !title)) { - return BigValueTextMode.ValueAndName; - } + if (hasLinks && getLinks) { + return ( + + {(api) => { + return renderComponent(valueProps, api); + }} + + ); + } - return options.textMode; - } + return renderComponent(valueProps, {}); + }, + [renderComponent] + ); - renderValue = (valueProps: VizRepeaterRenderValueProps): JSX.Element => { - const { value } = valueProps; - const { getLinks, hasLinks } = value; + const getValues = useCallback((): FieldDisplay[] => { + let globalRange: NumericRange | undefined = undefined; - if (hasLinks && getLinks) { - return ( - - {(api) => { - return this.renderComponent(valueProps, api); - }} - - ); - } - - return this.renderComponent(valueProps, {}); - }; - - getValues = (): FieldDisplay[] => { - const { data, options, replaceVariables, fieldConfig, timeZone } = this.props; - - let globalRange: NumericRange | undefined = undefined; - - for (let frame of data.series) { - for (let field of frame.fields) { - let { config } = field; - // mostly copied from fieldOverrides, since they are skipped during streaming - // Set the Min/Max value automatically - if (field.type === FieldType.number) { - if (field.state?.range) { - continue; + for (let frame of data.series) { + for (let field of frame.fields) { + let { config } = field; + // mostly copied from fieldOverrides, since they are skipped during streaming + // Set the Min/Max value automatically + if (field.type === FieldType.number) { + if (field.state?.range) { + continue; + } + if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) { + globalRange = findNumericFieldMinMax(data.series); + } + const min = config.min ?? globalRange!.min; + const max = config.max ?? globalRange!.max; + field.state = field.state ?? {}; + field.state.range = { min, max, delta: max! - min! }; } - if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) { - globalRange = findNumericFieldMinMax(data.series); - } - const min = config.min ?? globalRange!.min; - const max = config.max ?? globalRange!.max; - field.state = field.state ?? {}; - field.state.range = { min, max, delta: max! - min! }; } } - } - return getFieldDisplayValues({ - fieldConfig, - reduceOptions: options.reduceOptions, - replaceVariables, - theme: config.theme2, - data: data.series, - sparkline: options.graphMode !== BigValueGraphMode.None, - percentChange: options.showPercentChange, - timeZone, - }); - }; - - render() { - const { height, options, width, data, renderCounter } = this.props; + return getFieldDisplayValues({ + fieldConfig, + reduceOptions: options.reduceOptions, + replaceVariables, + theme, + data: data.series, + sparkline: options.graphMode !== BigValueGraphMode.None, + percentChange: options.showPercentChange, + timeZone, + }); + }, [data, fieldConfig, theme, options, replaceVariables, timeZone]); return ( > { /> ); } -} +); +StatPanel.displayName = 'StatPanel'; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index dcbf60af7b5..2d5bd426837 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8782,6 +8782,9 @@ "drawer": { "close": "Close" }, + "error-boundary": { + "title": "An unexpected error happened" + }, "feature-badge": { "experimental": "Experimental", "new": "New!", From d399f116b805ba210e09040e5fdc6c008a0e9b5e Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 30 Oct 2025 10:28:15 +0100 Subject: [PATCH 002/555] Alerting: Improve instance details drawer in Alerts (#113106) * Add alert instance breadcrumbs, change instance drawer title * Update translations * Add instance drawer title component and unify its usage --- .../InstanceDetailsDrawer.tsx | 37 +++++++++++++----- .../InstanceDetailsDrawerTitle.tsx | 39 +++++++++++++++++++ public/locales/en-US/grafana.json | 7 ++-- 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawerTitle.tsx diff --git a/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx index 704c4d54c47..7ce7e9ae6f7 100644 --- a/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx +++ b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx @@ -3,12 +3,11 @@ import { orderBy } from 'lodash'; import { Fragment, useMemo } from 'react'; import { useMeasure } from 'react-use'; -import { AlertLabels } from '@grafana/alerting/unstable'; import { GrafanaTheme2, Labels } from '@grafana/data'; import { t } from '@grafana/i18n'; import { isFetchError } from '@grafana/runtime'; import { TimeRangePicker, useTimeRange } from '@grafana/scenes-react'; -import { Alert, Box, Drawer, Icon, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, Box, Drawer, Icon, LoadingBar, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui'; import { AlertQuery, GrafanaRuleDefinition } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; @@ -19,6 +18,7 @@ import { LogRecord, historyDataFrameToLogRecords } from '../../components/rules/ import { isAlertQueryOfAlertData } from '../../rule-editor/formProcessing'; import { stringifyErrorLike } from '../../utils/misc'; +import { InstanceDetailsDrawerTitle } from './InstanceDetailsDrawerTitle'; import { QueryVisualization } from './QueryVisualization'; import { convertStateHistoryToAnnotations } from './stateHistoryUtils'; @@ -66,7 +66,7 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst if (error) { return ( - + } onClose={onClose} size="md"> ); @@ -74,15 +74,15 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst if (loading || !rule) { return ( - -
{t('alerting.common.loading', 'Loading...')}
+ } onClose={onClose} size="md"> + ); } return ( } onClose={onClose} size="lg" > @@ -106,10 +106,6 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst )} - - - - {t('alerting.instance-details.state-history', 'Recent State Changes')} {stateHistoryFetching && } @@ -139,6 +135,27 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst ); } +export interface InstanceLocationProps { + folderTitle: string; + groupName: string; + ruleName: string; +} + +export function InstanceLocation({ folderTitle, groupName, ruleName }: InstanceLocationProps) { + return ( + + + + {folderTitle} + + {groupName} + + {ruleName} + + + ); +} + function extractQueryDetails(rule: GrafanaRuleDefinition) { const dataQueries = rule.data.filter((query: AlertQuery) => isAlertQueryOfAlertData(query)); diff --git a/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawerTitle.tsx b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawerTitle.tsx new file mode 100644 index 00000000000..1b07cdb284f --- /dev/null +++ b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawerTitle.tsx @@ -0,0 +1,39 @@ +import { AlertLabels } from '@grafana/alerting/unstable'; +import { Labels } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Box, Stack, Text } from '@grafana/ui'; +import { GrafanaRuleDefinition } from 'app/types/unified-alerting-dto'; + +import { stringifyFolder, useFolder } from '../../hooks/useFolder'; + +import { InstanceLocation } from './InstanceDetailsDrawer'; + +interface InstanceDetailsDrawerTitleProps { + instanceLabels: Labels; + rule?: GrafanaRuleDefinition; +} + +export function InstanceDetailsDrawerTitle({ instanceLabels, rule }: InstanceDetailsDrawerTitleProps) { + const { folder } = useFolder(rule?.namespace_uid); + + return ( + + + Instance details + + + + {Object.keys(instanceLabels).length > 0 ? ( + + ) : ( + {t('alerting.triage.no-labels', 'No labels')} + )} + + + + {folder && rule && ( + + )} + + ); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2d5bd426837..b687292e3ad 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1662,9 +1662,6 @@ "no-history": "No recent state changes", "state-history": "Recent State Changes" }, - "instance-details-drawer": { - "title-instance-details": "Instance Details" - }, "instance-match": { "non-matching-labels": "Non-matching labels", "notification-policy": "View route" @@ -2950,7 +2947,9 @@ "alert-instances": "Alert instances", "error-loading-rule": "Error loading rule", "firing-instances-count": "{{firingCount}} firing instances", - "instance-details": "Instance Details", + "instance-details-drawer": { + "instance-details": "Instance details" + }, "no-instances-found": "No alert instances found for rule: {{ruleUID}}", "no-labels": "No labels", "open-in-sidebar": "Open in sidebar", From ee62a8d431c00e510b1664f899d9bbef06f11ba2 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 30 Oct 2025 10:43:13 +0100 Subject: [PATCH 003/555] Alerting: Alerts page improvements (#113172) * Remove column header from Triage workbench * Use md size for details Drawers * Fix top workbench border * Fix query filter for alert instances * Remove the default grouping * Update translations --- .../alerting/unified/triage/Timeline.tsx | 37 ------------------- .../alerting/unified/triage/Workbench.tsx | 9 ----- .../InstanceDetailsDrawer.tsx | 2 +- .../unified/triage/rows/GenericRow.tsx | 5 +-- .../triage/rule-details/RuleDetailsDrawer.tsx | 7 ++-- .../triage/scene/AlertRuleInstances.tsx | 6 ++- .../unified/triage/scene/TriageScene.tsx | 1 - public/locales/en-US/grafana.json | 3 -- 8 files changed, 9 insertions(+), 61 deletions(-) delete mode 100644 public/app/features/alerting/unified/triage/Timeline.tsx diff --git a/public/app/features/alerting/unified/triage/Timeline.tsx b/public/app/features/alerting/unified/triage/Timeline.tsx deleted file mode 100644 index 3bde44c91b9..00000000000 --- a/public/app/features/alerting/unified/triage/Timeline.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { scaleTime } from 'd3-scale'; -import { useMemo } from 'react'; -import { useMeasure } from 'react-use'; - -import { Stack, Text } from '@grafana/ui'; - -import { Domain } from './types'; - -interface TimelineProps { - domain: Domain; -} - -export const TimelineHeader = ({ domain }: TimelineProps) => { - const [ref, { width }] = useMeasure(); - - const ticks = useMemo(() => { - const xScale = scaleTime().domain(domain).range([0, width]).nice(0); - const tickFormatter = xScale.tickFormat(); - - return xScale.ticks(5).map((value) => ({ - value: tickFormatter(value), - xOffset: xScale(value), - })); - }, [domain, width]); - - return ( -
- - {ticks.map((tick) => ( - - {tick.value} - - ))} - -
- ); -}; diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx index 85a4788a706..ceb4196ad7f 100644 --- a/public/app/features/alerting/unified/triage/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -4,15 +4,12 @@ import { useState } from 'react'; import { useMeasure } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { t } from '@grafana/i18n'; import { SceneQueryRunner } from '@grafana/scenes'; import { ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui'; import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants'; -import { EditorColumnHeader } from '../components/EditorColumnHeader'; import LoadMoreHelper from '../rule-list/LoadMoreHelper'; -import { TimelineHeader } from './Timeline'; import { WorkbenchProvider } from './WorkbenchContext'; import { AlertRuleRow } from './rows/AlertRuleRow'; import { FolderGroupRow } from './rows/FolderGroupRow'; @@ -141,12 +138,6 @@ export function Workbench({ domain, data, queryRunner }: WorkbenchProps) {
-
- - - - -
{/* Render actual data */}
diff --git a/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx index 7ce7e9ae6f7..617f097ce89 100644 --- a/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx +++ b/public/app/features/alerting/unified/triage/instance-details/InstanceDetailsDrawer.tsx @@ -84,7 +84,7 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst } onClose={onClose} - size="lg" + size="md" > diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx index 904687a4dbf..edc475fbfe1 100644 --- a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -108,10 +108,7 @@ export const getStyles = (theme: GrafanaTheme2) => { display: 'flex', position: 'relative', flexBasis: 0, - border: 'solid 1px transparent', - borderBottom: `1px solid ${theme.colors.border.medium}`, - borderLeft: `1px solid ${theme.colors.border.medium}`, - borderRight: `1px solid ${theme.colors.border.medium}`, + border: `1px solid ${theme.colors.border.medium}`, }), leftColumn: css({ overflow: 'hidden', diff --git a/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx b/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx index 69687456341..980f8272f43 100644 --- a/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx +++ b/public/app/features/alerting/unified/triage/rule-details/RuleDetailsDrawer.tsx @@ -48,7 +48,7 @@ export function RuleDetailsDrawer({ ruleUID, onClose }: RuleDetailsDrawerProps) if (error) { return ( - + ); @@ -56,7 +56,7 @@ export function RuleDetailsDrawer({ ruleUID, onClose }: RuleDetailsDrawerProps) if (loading || !rule) { return ( - +
{t('alerting.common.loading', 'Loading...')}
); @@ -69,7 +69,6 @@ export function RuleDetailsDrawer({ ruleUID, onClose }: RuleDetailsDrawerProps) return ( @@ -104,7 +103,7 @@ export function RuleDetailsDrawer({ ruleUID, onClose }: RuleDetailsDrawerProps) {t('alerting.triage.rule-details.subtitle', 'Rule details and conditions')} } - size="lg" + size="md" tabs={ Date: Thu, 30 Oct 2025 10:49:54 +0100 Subject: [PATCH 004/555] PanelEditor: Fixes double top border around Queries (#112865) * PanelEditor: Fixes double top border around Queries * Fixes --- .../PanelDataPane/PanelDataPane.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx index 943637d2722..4e7d10fa22f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -80,14 +80,16 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { return (
- + {tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))} - - - {currentTab && } - - +
+ + + {currentTab && } + + +
); } @@ -116,13 +118,18 @@ function getStyles(theme: GrafanaTheme2) { height: '100%', width: '100%', }), - tabContent: css({ - padding: theme.spacing(2), + tabBorder: css({ + background: theme.colors.background.primary, border: `1px solid ${theme.colors.border.weak}`, borderLeft: 'none', borderBottom: 'none', borderTopRightRadius: theme.shape.radius.default, flexGrow: 1, + overflow: 'hidden', + }), + tabContent: css({ + padding: theme.spacing(2), + height: '100%', }), tabsBar: css({ flexShrink: 0, From 344fc5606fa27146613d5406628a95ccd70808a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 30 Oct 2025 10:50:05 +0100 Subject: [PATCH 005/555] PanelTimeCompare: Support saving time compare window (#113150) * PanelTimeCompare: Support saving time compare window * fix indentation * Fix merge issue * Update * Update * make gen-cue --------- Co-authored-by: oscarkilhed --- apps/dashboard/kinds/v2beta1/dashboard_spec.cue | 1 + .../pkg/apis/dashboard/v0alpha1/dashboard_kind.cue | 4 ++++ .../dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue | 4 ++++ .../dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue | 1 + .../pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go | 1 + .../pkg/apis/dashboard/v2beta1/zz_generated.openapi.go | 6 ++++++ kinds/dashboard/dashboard_kind.cue | 4 ++++ .../src/raw/dashboard/x/dashboard_types.gen.ts | 5 +++++ .../src/schema/dashboard/v2alpha0/dashboard.schema.cue | 1 + .../src/schema/dashboard/v2beta1/types.spec.gen.ts | 1 + pkg/kinds/dashboard/dashboard_spec_gen.go | 3 +++ .../serialization/transformSaveModelToScene.ts | 3 ++- .../serialization/transformSceneToSaveModel.ts | 1 + .../serialization/transformSceneToSaveModelSchemaV2.ts | 1 + public/app/features/dashboard/state/PanelModel.ts | 1 + 15 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index f0278fc431c..ef1490091c2 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -452,6 +452,7 @@ QueryOptionsSpec: { interval?: string cacheTimeout?: string hideTimeOverride?: bool + timeCompare?: string } DataQueryKind: { diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index 526ddba3bfc..7d8a0e551bf 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -670,6 +670,10 @@ lineage: schemas: [{ // Controls if the timeFrom or timeShift overrides are shown in the panel header hideTimeOverride?: bool + // Compare the current time range with a previous period + // For example "1d" to compare current period but shifted back 1 day + timeCompare?: string + // Dynamically load the panel libraryPanel?: #LibraryPanelRef diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index 526ddba3bfc..7d8a0e551bf 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -670,6 +670,10 @@ lineage: schemas: [{ // Controls if the timeFrom or timeShift overrides are shown in the panel header hideTimeOverride?: bool + // Compare the current time range with a previous period + // For example "1d" to compare current period but shifted back 1 day + timeCompare?: string + // Dynamically load the panel libraryPanel?: #LibraryPanelRef diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index fb46afb5c76..31603c6240b 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -456,6 +456,7 @@ QueryOptionsSpec: { interval?: string cacheTimeout?: string hideTimeOverride?: bool + timeCompare?: string } DataQueryKind: { diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index 35674997511..4229e5765d1 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -278,6 +278,7 @@ type DashboardQueryOptionsSpec struct { Interval *string `json:"interval,omitempty"` CacheTimeout *string `json:"cacheTimeout,omitempty"` HideTimeOverride *bool `json:"hideTimeOverride,omitempty"` + TimeCompare *string `json:"timeCompare,omitempty"` } // NewDashboardQueryOptionsSpec creates a new DashboardQueryOptionsSpec object. diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index 5e51134e80c..97fb1c10326 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -3426,6 +3426,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardQueryOptionsSpec(ref common.Refe Format: "", }, }, + "timeCompare": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 13c2b4f25f6..e32b8b6725e 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -666,6 +666,10 @@ lineage: schemas: [{ // Controls if the timeFrom or timeShift overrides are shown in the panel header hideTimeOverride?: bool + // Compare the current time range with a previous period + // For example "1d" to compare current period but shifted back 1 day + timeCompare?: string + // Dynamically load the panel libraryPanel?: #LibraryPanelRef diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index b370fc6206c..973f10bfb21 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -885,6 +885,11 @@ export interface Panel { * Depends on the panel plugin. See the plugin documentation for details. */ targets?: Array>; + /** + * Compare the current time range with a previous period + * For example "1d" to compare current period but shifted back 1 day + */ + timeCompare?: string; /** * Overrides the relative time range for individual panels, * which causes them to be different than what is selected in diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index 5369503d6f2..ba637656ed8 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -409,6 +409,7 @@ QueryOptionsSpec: { interval?: string cacheTimeout?: string hideTimeOverride?: bool + timeCompare?: string } DataQueryKind: { diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 19c324f1cd9..e9cbe9e649a 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -219,6 +219,7 @@ export interface QueryOptionsSpec { interval?: string; cacheTimeout?: string; hideTimeOverride?: boolean; + timeCompare?: string; } export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({ diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 2ebe81760c9..43877fbca7b 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -191,6 +191,9 @@ type Panel struct { TimeShift *string `json:"timeShift,omitempty"` // Controls if the timeFrom or timeShift overrides are shown in the panel header HideTimeOverride *bool `json:"hideTimeOverride,omitempty"` + // Compare the current time range with a previous period + // For example "1d" to compare current period but shifted back 1 day + TimeCompare *string `json:"timeCompare,omitempty"` // Dynamically load the panel LibraryPanel *LibraryPanelRef `json:"libraryPanel,omitempty"` // Sets panel queries cache timeout. diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index aab1f181854..7646b7df68c 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -456,11 +456,12 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { }); } - if (panel.timeFrom || panel.timeShift) { + if (panel.timeFrom || panel.timeShift || panel.timeCompare) { vizPanelState.$timeRange = new PanelTimeRange({ timeFrom: panel.timeFrom, timeShift: panel.timeShift, hideTimeOverride: panel.hideTimeOverride, + compareWith: panel.timeCompare, }); } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 16b3957435f..bd5a1139848 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -232,6 +232,7 @@ export function vizPanelToPanel( panel.timeFrom = panelTime.state.timeFrom; panel.timeShift = panelTime.state.timeShift; panel.hideTimeOverride = panelTime.state.hideTimeOverride; + panel.timeCompare = panelTime.state.compareWith; } if (gridItem instanceof DashboardGridItem) { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index da7e19d3a41..85cd9e8ee7b 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -387,6 +387,7 @@ function getVizPanelQueryOptions(vizPanel: VizPanel): QueryOptionsSpec { queryOptions.timeFrom = panelTime.state.timeFrom; queryOptions.timeShift = panelTime.state.timeShift; queryOptions.hideTimeOverride = panelTime.state.hideTimeOverride; + queryOptions.timeCompare = panelTime.state.compareWith; } return queryOptions; } diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 7e67a270ed4..4b06df7c8bc 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -170,6 +170,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { timeFrom?: any; timeShift?: any; hideTimeOverride?: boolean; + timeCompare?: string; declare options: { [key: string]: any; }; From cb86be2e32d0aef062e3d313addf99ff156e8cf3 Mon Sep 17 00:00:00 2001 From: maicon Date: Thu, 30 Oct 2025 07:51:31 -0300 Subject: [PATCH 006/555] Unistore: ensure dashboard DeleteInFolders work on both storages (#113197) Signed-off-by: Maicon Costa --- .../dashboards/service/dashboard_service.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 3d26169edd7..f59416924de 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -1638,12 +1638,14 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64 defer span.End() // We need a list of dashboard uids inside the folder to delete related public dashboards - dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ + dashes, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ SignedInUser: u, - FolderUIDs: folderUIDs, - OrgId: orgID, Type: searchstore.TypeDashboard, + Limit: 100000, + OrgId: orgID, + FolderUIDs: folderUIDs, }) + if err != nil { return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err) } @@ -1658,7 +1660,14 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64 return err } - return dr.dashboardStore.DeleteDashboardsInFolders(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID}) + for _, dash := range dashes { + errDel := dr.DeleteDashboard(ctx, dash.ID, dash.UID, orgID) + if errDel != nil { + dr.log.Error("failed to delete dashboard inside folder", "dashboardUID", dash.UID, "folderUIDs", folderUIDs, "error", errDel) + } + } + + return err } func (dr *DashboardServiceImpl) Kind() string { return entity.StandardKindDashboard } From bbfb8268d1502bb33dbe7cd7bcf07c4f79fead68 Mon Sep 17 00:00:00 2001 From: Costa Alexoglou Date: Thu, 30 Oct 2025 11:55:36 +0100 Subject: [PATCH 007/555] Provisioning: concurrent deletes in finalizers and 404 handling (#113155) * fix: concurrent deletes in finalizers and 404 handling * chore: feedback review * fix: broken tests --- .../apis/dashboard/cuevalidator/validator.go | 29 +++ .../pkg/apis/dashboard/v0alpha1/validation.go | 15 +- .../pkg/apis/dashboard/v1beta1/validation.go | 15 +- .../pkg/apis/dashboard/v2alpha1/validation.go | 16 +- .../pkg/apis/dashboard/v2beta1/validation.go | 16 +- pkg/operators/provisioning/repo_operator.go | 3 + .../provisioning/controller/finalizers.go | 64 +++++- .../controller/finalizers_test.go | 182 ++++++++++++++++-- .../provisioning/controller/repository.go | 2 + pkg/registry/apis/provisioning/register.go | 1 + 10 files changed, 290 insertions(+), 53 deletions(-) create mode 100644 apps/dashboard/pkg/apis/dashboard/cuevalidator/validator.go diff --git a/apps/dashboard/pkg/apis/dashboard/cuevalidator/validator.go b/apps/dashboard/pkg/apis/dashboard/cuevalidator/validator.go new file mode 100644 index 00000000000..38792ade026 --- /dev/null +++ b/apps/dashboard/pkg/apis/dashboard/cuevalidator/validator.go @@ -0,0 +1,29 @@ +package cuevalidator + +import ( + "sync" + + "cuelang.org/go/cue" + cuejson "cuelang.org/go/encoding/json" +) + +// Validator provides thread-safe CUE schema validation. +// +// CUE is not safe for concurrent use: https://github.com/cue-lang/cue/discussions/1205#discussioncomment-1189238 +// This validator uses a mutex to protect concurrent access to the underlying CUE validation. +type Validator struct { + schema cue.Value + mu sync.Mutex +} + +func NewValidator(schema cue.Value) *Validator { + return &Validator{ + schema: schema, + } +} + +func (v *Validator) Validate(data []byte) error { + v.mu.Lock() + defer v.mu.Unlock() + return cuejson.Validate(data, v.schema) +} diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go index 6fdf1dd4515..7c5573f600a 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/validation.go @@ -7,13 +7,13 @@ import ( "strings" "sync" + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/cuevalidator" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "k8s.io/apimachinery/pkg/util/validation/field" "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" - cuejson "cuelang.org/go/encoding/json" ) func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorList, field.ErrorList) { @@ -33,7 +33,7 @@ func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorLis }, schemaVersionError } - if err := cuejson.Validate(data, getCueSchema()); err != nil { + if err := getValidator().Validate(data); err != nil { errs := field.ErrorList{} for _, e := range errors.Errors(err) { @@ -71,20 +71,21 @@ func formatErrorPath(path []string) string { } var ( - compiledSchema cue.Value - getSchemaOnce sync.Once + validator *cuevalidator.Validator + getSchemaOnce sync.Once ) //go:embed dashboard_kind.cue var schemaSource string -func getCueSchema() cue.Value { +func getValidator() *cuevalidator.Validator { getSchemaOnce.Do(func() { cueCtx := cuecontext.New() - compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + compiledSchema := cueCtx.CompileString(schemaSource).LookupPath( cue.ParsePath("lineage.schemas[0].schema.spec"), ) + validator = cuevalidator.NewValidator(compiledSchema) }) - return compiledSchema + return validator } diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go b/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go index 7020aa46b90..b48e38ffd7b 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/validation.go @@ -12,8 +12,8 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" - cuejson "cuelang.org/go/encoding/json" + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/cuevalidator" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" ) @@ -34,7 +34,7 @@ func ValidateDashboardSpec(obj *Dashboard, forceValidation bool) (field.ErrorLis }, schemaVersionError } - if err := cuejson.Validate(data, getCueSchema()); err != nil { + if err := getValidator().Validate(data); err != nil { errs := field.ErrorList{} for _, e := range errors.Errors(err) { @@ -72,20 +72,21 @@ func formatErrorPath(path []string) string { } var ( - compiledSchema cue.Value - getSchemaOnce sync.Once + validator *cuevalidator.Validator + getSchemaOnce sync.Once ) //go:embed dashboard_kind.cue var schemaSource string -func getCueSchema() cue.Value { +func getValidator() *cuevalidator.Validator { getSchemaOnce.Do(func() { cueCtx := cuecontext.New() - compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + compiledSchema := cueCtx.CompileString(schemaSource).LookupPath( cue.ParsePath("lineage.schemas[0].schema.spec"), ) + validator = cuevalidator.NewValidator(compiledSchema) }) - return compiledSchema + return validator } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go index 7c61faa8924..ca9dcd3e514 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/validation.go @@ -12,7 +12,8 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" - cuejson "cuelang.org/go/encoding/json" + + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/cuevalidator" ) func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { @@ -26,7 +27,7 @@ func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { // Custom validation for action query params and headers validateAndTrimActionArrays(obj) - if err := cuejson.Validate(data, getCueSchema()); err != nil { + if err := getValidator().Validate(data); err != nil { errs := field.ErrorList{} for _, e := range errors.Errors(err) { @@ -123,20 +124,21 @@ func formatErrorPath(path []string) string { } var ( - compiledSchema cue.Value - getSchemaOnce sync.Once + validator *cuevalidator.Validator + getSchemaOnce sync.Once ) //go:embed dashboard_spec.cue var schemaSource string -func getCueSchema() cue.Value { +func getValidator() *cuevalidator.Validator { getSchemaOnce.Do(func() { cueCtx := cuecontext.New() - compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + compiledSchema := cueCtx.CompileString(schemaSource).LookupPath( cue.ParsePath("DashboardSpec"), ) + validator = cuevalidator.NewValidator(compiledSchema) }) - return compiledSchema + return validator } diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go index 7c859626b98..518c133bcd7 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/validation.go @@ -12,7 +12,8 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" "cuelang.org/go/cue/errors" - cuejson "cuelang.org/go/encoding/json" + + "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/cuevalidator" ) func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { @@ -26,7 +27,7 @@ func ValidateDashboardSpec(obj *Dashboard) field.ErrorList { // Custom validation for action query params and headers validateAndTrimActionArrays(obj) - if err := cuejson.Validate(data, getCueSchema()); err != nil { + if err := getValidator().Validate(data); err != nil { errs := field.ErrorList{} for _, e := range errors.Errors(err) { @@ -123,20 +124,21 @@ func formatErrorPath(path []string) string { } var ( - compiledSchema cue.Value - getSchemaOnce sync.Once + validator *cuevalidator.Validator + getSchemaOnce sync.Once ) //go:embed dashboard_spec.cue var schemaSource string -func getCueSchema() cue.Value { +func getValidator() *cuevalidator.Validator { getSchemaOnce.Do(func() { cueCtx := cuecontext.New() - compiledSchema = cueCtx.CompileString(schemaSource).LookupPath( + compiledSchema := cueCtx.CompileString(schemaSource).LookupPath( cue.ParsePath("DashboardSpec"), ) + validator = cuevalidator.NewValidator(compiledSchema) }) - return compiledSchema + return validator } diff --git a/pkg/operators/provisioning/repo_operator.go b/pkg/operators/provisioning/repo_operator.go index f96653a1d87..1651a126ffe 100644 --- a/pkg/operators/provisioning/repo_operator.go +++ b/pkg/operators/provisioning/repo_operator.go @@ -90,6 +90,7 @@ func RunRepoController(deps server.OperatorDependencies) error { statusPatcher, deps.Registerer, tracer, + controllerCfg.parallelOperations, ) if err != nil { return fmt.Errorf("failed to create repository controller: %w", err) @@ -107,6 +108,7 @@ func RunRepoController(deps server.OperatorDependencies) error { type repoControllerConfig struct { provisioningControllerConfig workerCount int + parallelOperations int allowedTargets []string allowImageRendering bool minSyncInterval time.Duration @@ -128,6 +130,7 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( provisioningControllerConfig: *controllerCfg, allowedTargets: allowedTargets, workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), + parallelOperations: cfg.SectionWithEnvOverrides("operator").Key("parallel_operations").MustInt(10), allowImageRendering: cfg.SectionWithEnvOverrides("provisioning").Key("allow_image_rendering").MustBool(false), minSyncInterval: cfg.SectionWithEnvOverrides("provisioning").Key("min_sync_interval").MustDuration(1 * time.Minute), }, nil diff --git a/pkg/registry/apis/provisioning/controller/finalizers.go b/pkg/registry/apis/provisioning/controller/finalizers.go index d9c41ee4c15..5bd468fe660 100644 --- a/pkg/registry/apis/provisioning/controller/finalizers.go +++ b/pkg/registry/apis/provisioning/controller/finalizers.go @@ -7,8 +7,11 @@ import ( "slices" "sort" "strings" + "sync/atomic" "time" + "github.com/grafana/dskit/concurrency" + "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" @@ -27,6 +30,7 @@ type finalizer struct { lister resources.ResourceLister clientFactory resources.ClientFactory metrics *finalizerMetrics + maxWorkers int } func (f *finalizer) process(ctx context.Context, @@ -113,27 +117,73 @@ func (f *finalizer) processExistingItems( // Safe deletion order sortResourceListForDeletion(items) - count := 0 + var dashboards, folderItems []*provisioning.ResourceListItem for _, item := range items.Items { - res, _, err := clients.ForResource(ctx, schema.GroupVersionResource{ + if item.Group == folders.GroupVersion.Group { + folderItems = append(folderItems, &item) + } else { + dashboards = append(dashboards, &item) + } + } + + processItem := func(jobCtx context.Context, item *provisioning.ResourceListItem) error { + res, _, err := clients.ForResource(jobCtx, schema.GroupVersionResource{ Group: item.Group, Resource: item.Resource, }) if err != nil { logger.Error("error getting client for resource", "resource", item.Resource, "error", err) - return count, err + return err } - err = cb(res, &item) + err = cb(res, item) if err != nil { + if errors.IsNotFound(err) { + logger.Info("resource not found, skipping", "name", item.Name, "group", item.Group, "resource", item.Resource) + return nil + } logger.Error("error processing item", "name", item.Name, "error", err) - return count, fmt.Errorf("processing item: %w", err) - } else { + return fmt.Errorf("processing item: %w", err) + } + return nil + } + + processGroup := func(group []*provisioning.ResourceListItem) (int, error) { + var processed int64 + err := concurrency.ForEachJob(ctx, len(group), f.maxWorkers, func(ctx context.Context, idx int) error { + jobCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + item := group[idx] + if err := processItem(jobCtx, item); err != nil { + return err + } + atomic.AddInt64(&processed, 1) + return nil + }) + return int(processed), err + } + + count := 0 + + if len(dashboards) > 0 { + processed, err := processGroup(dashboards) + if err != nil { + return processed, err + } + count += processed + } + + if len(folderItems) > 0 { + for _, item := range folderItems { + if err := processItem(ctx, item); err != nil { + return count, err + } count++ } } - logger.Info("processed orphan items", "items", count) + + logger.Info("processed items", "items", count) return count, nil } diff --git a/pkg/registry/apis/provisioning/controller/finalizers_test.go b/pkg/registry/apis/provisioning/controller/finalizers_test.go index 140363a0507..4df89107bbe 100644 --- a/pkg/registry/apis/provisioning/controller/finalizers_test.go +++ b/pkg/registry/apis/provisioning/controller/finalizers_test.go @@ -2,10 +2,15 @@ package controller import ( "context" + "fmt" + "sync" + "sync/atomic" "testing" + "time" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" + mock "github.com/stretchr/testify/mock" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -14,6 +19,8 @@ import ( "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" + "github.com/grafana/grafana-app-sdk/logging" + folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" @@ -140,7 +147,7 @@ func TestFinalizer_process(t *testing.T) { resourceLister := resources.NewMockResourceLister(t) resourceLister. - On("List", context.Background(), "default", "my-repo"). + On("List", mock.Anything, "default", "my-repo"). Once(). Return(&provisioning.ResourceList{ Items: []provisioning.ResourceListItem{ @@ -164,12 +171,12 @@ func TestFinalizer_process(t *testing.T) { } clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(clients, nil) clients. - On("ForResource", context.Background(), schema.GroupVersionResource{ + On("ForResource", mock.Anything, schema.GroupVersionResource{ Group: "dashboard.grafana.app", Resource: "dashboards", }). @@ -196,7 +203,7 @@ func TestFinalizer_process(t *testing.T) { resourceLister := resources.NewMockResourceLister(t) resourceLister. - On("List", context.Background(), "default", "my-repo"). + On("List", mock.Anything, "default", "my-repo"). Once(). Return(&provisioning.ResourceList{ Items: []provisioning.ResourceListItem{ @@ -220,12 +227,12 @@ func TestFinalizer_process(t *testing.T) { } clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(clients, nil) clients. - On("ForResource", context.Background(), schema.GroupVersionResource{ + On("ForResource", mock.Anything, schema.GroupVersionResource{ Group: "dashboard.grafana.app", Resource: "dashboards", }). @@ -253,7 +260,7 @@ func TestFinalizer_process(t *testing.T) { clientFactory := resources.NewMockClientFactory(t) clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(nil, assert.AnError) @@ -275,7 +282,7 @@ func TestFinalizer_process(t *testing.T) { resourceLister := resources.NewMockResourceLister(t) resourceLister. - On("List", context.Background(), "default", "my-repo"). + On("List", mock.Anything, "default", "my-repo"). Once(). Return(nil, assert.AnError) @@ -286,7 +293,7 @@ func TestFinalizer_process(t *testing.T) { clients := resources.NewMockResourceClients(t) clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(clients, nil) @@ -308,7 +315,7 @@ func TestFinalizer_process(t *testing.T) { resourceLister := resources.NewMockResourceLister(t) resourceLister. - On("List", context.Background(), "default", "my-repo"). + On("List", mock.Anything, "default", "my-repo"). Once(). Return(&provisioning.ResourceList{ Items: []provisioning.ResourceListItem{ @@ -327,12 +334,12 @@ func TestFinalizer_process(t *testing.T) { clients := resources.NewMockResourceClients(t) clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(clients, nil) clients. - On("ForResource", context.Background(), schema.GroupVersionResource{ + On("ForResource", mock.Anything, schema.GroupVersionResource{ Group: "dashboard.grafana.app", Resource: "dashboards", }). @@ -357,7 +364,7 @@ func TestFinalizer_process(t *testing.T) { resourceLister := resources.NewMockResourceLister(t) resourceLister. - On("List", context.Background(), "default", "my-repo"). + On("List", mock.Anything, "default", "my-repo"). Once(). Return(&provisioning.ResourceList{ Items: []provisioning.ResourceListItem{ @@ -381,12 +388,12 @@ func TestFinalizer_process(t *testing.T) { } clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(clients, nil) clients. - On("ForResource", context.Background(), schema.GroupVersionResource{ + On("ForResource", mock.Anything, schema.GroupVersionResource{ Group: "dashboard.grafana.app", Resource: "dashboards", }). @@ -414,7 +421,7 @@ func TestFinalizer_process(t *testing.T) { resourceLister := resources.NewMockResourceLister(t) resourceLister. - On("List", context.Background(), "default", "my-repo"). + On("List", mock.Anything, "default", "my-repo"). Once(). Return(&provisioning.ResourceList{ Items: []provisioning.ResourceListItem{ @@ -438,12 +445,12 @@ func TestFinalizer_process(t *testing.T) { } clientFactory. - On("Clients", context.Background(), "default"). + On("Clients", mock.Anything, "default"). Once(). Return(clients, nil) clients. - On("ForResource", context.Background(), schema.GroupVersionResource{ + On("ForResource", mock.Anything, schema.GroupVersionResource{ Group: "dashboard.grafana.app", Resource: "dashboards", }). @@ -560,3 +567,142 @@ func TestSortResourceListForDeletion(t *testing.T) { }) } } + +func TestFinalizer_processExistingItems_Concurrency(t *testing.T) { + testCases := []struct { + name string + dashboardCount int + folderCount int + maxWorkers int + expectedConcurrency bool + }{ + { + name: "Multiple dashboards processed concurrently", + dashboardCount: 10, + folderCount: 0, + maxWorkers: 5, + expectedConcurrency: true, + }, + { + name: "Single worker processes dashboards sequentially", + dashboardCount: 5, + folderCount: 0, + maxWorkers: 1, + expectedConcurrency: false, + }, + { + name: "Folders processed sequentially regardless of maxWorkers", + dashboardCount: 0, + folderCount: 5, + maxWorkers: 10, + expectedConcurrency: false, + }, + { + name: "Mixed dashboards and folders - dashboards concurrent, folders sequential", + dashboardCount: 10, + folderCount: 3, + maxWorkers: 5, + expectedConcurrency: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Will be used to track concurrent executions + var ( + concurrentCount int64 + maxConcurrent int64 + mu sync.Mutex + ) + + items := provisioning.ResourceList{Items: []provisioning.ResourceListItem{}} + + for i := 0; i < tc.dashboardCount; i++ { + items.Items = append(items.Items, provisioning.ResourceListItem{ + Group: "dashboard.grafana.app", + Resource: "dashboards", + Name: fmt.Sprintf("dashboard-%d", i), + }) + } + + for i := 0; i < tc.folderCount; i++ { + items.Items = append(items.Items, provisioning.ResourceListItem{ + Group: folders.GroupVersion.Group, + Resource: "folders", + Name: fmt.Sprintf("folder-%d", i), + }) + } + + resourceLister := resources.NewMockResourceLister(t) + resourceLister. + On("List", mock.Anything, "default", "my-repo"). + Return(&items, nil) + + clientFactory := resources.NewMockClientFactory(t) + clients := resources.NewMockResourceClients(t) + + client := &mockDynamicClient{ + deleteFunc: func(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + // Track concurrent executions + current := atomic.AddInt64(&concurrentCount, 1) + defer atomic.AddInt64(&concurrentCount, -1) + + mu.Lock() + if current > maxConcurrent { + maxConcurrent = current + } + mu.Unlock() + + // Simulate slow client to allow concurrency to build up + time.Sleep(1 * time.Second) + + return nil + }, + } + + clientFactory. + On("Clients", mock.Anything, "default"). + Return(clients, nil) + + clients. + On("ForResource", mock.Anything, mock.Anything). + Return(client, schema.GroupVersionKind{}, nil) + + metrics := registerFinalizerMetrics(prometheus.NewRegistry()) + f := &finalizer{ + lister: resourceLister, + clientFactory: clientFactory, + metrics: &metrics, + maxWorkers: tc.maxWorkers, + } + + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-repo", + Namespace: "default", + }, + } + + count, err := f.processExistingItems( + context.Background(), + repo, + f.removeResources(context.Background(), logging.DefaultLogger), + ) + + assert.NoError(t, err) + assert.Equal(t, tc.dashboardCount+tc.folderCount, count) + + if tc.expectedConcurrency { + // When concurrent, max concurrent should be > 1 + assert.Greater(t, maxConcurrent, int64(1), + "Expected concurrent execution but maxConcurrent was %d", maxConcurrent) + // Should not exceed maxWorkers + assert.LessOrEqual(t, maxConcurrent, int64(tc.maxWorkers)) + } else { + // When sequential, max concurrent should be 1 + assert.Equal(t, int64(1), maxConcurrent, + "Expected sequential execution but maxConcurrent was %d", maxConcurrent) + } + }) + } +} diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 70e5c3b4a87..681619711e1 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -84,6 +84,7 @@ func NewRepositoryController( statusPatcher StatusPatcher, registry prometheus.Registerer, tracer tracing.Tracer, + parallelOperations int, ) (*RepositoryController, error) { finalizerMetrics := registerFinalizerMetrics(registry) @@ -104,6 +105,7 @@ func NewRepositoryController( lister: resourceLister, clientFactory: clients, metrics: &finalizerMetrics, + maxWorkers: parallelOperations, }, jobs: jobs, logger: logging.DefaultLogger.With("logger", loggerName), diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 8797d940408..485f6bfe5d5 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -798,6 +798,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.statusPatcher, b.registry, b.tracer, + 10, ) if err != nil { return err From 5f2074e84ce339de098e1afa40a8c2487e242e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Thu, 30 Oct 2025 12:55:06 +0100 Subject: [PATCH 008/555] Explore: Use compact mode only when targeting Tempo (#113037) * Explore: Use compact mode only when targeting Tempo * Fix checking ds type when data source is not in the query object --- public/app/features/explore/Explore.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 5673fdbbbb9..f8bd275930b 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -312,16 +312,20 @@ export class Explore extends PureComponent { */ onSplitOpen = (panelType: string) => { return async (options?: SplitOpenOptions) => { - let compact = true; + let compact = false; /** * Temporary fix grafana-clickhouse-datasource as it requires the query editor to be fully rendered to update the query * Proposed fixes: * - https://github.com/grafana/clickhouse-datasource/issues/1363 - handle query update in data source * - https://github.com/grafana/grafana/issues/110868 - allow data links to provide meta info if the link can be handled in compact mode (default to false) + * Update: + * More data source may struggle with this setting: https://github.com/grafana/grafana/issues/112075 + * We're making it enabled for tempo only and will try to make it optional for other data sources in the future. */ - if (options?.queries?.some((q) => q.datasource?.type === 'grafana-clickhouse-datasource')) { - compact = false; + const dsType = getDataSourceSrv().getInstanceSettings({ uid: options?.datasourceUid })?.type; + if (dsType === 'tempo' || options?.queries?.every((q) => q.datasource?.type === 'tempo')) { + compact = true; } this.props.splitOpen(options ? { ...options, compact } : options); From 80d5cfa1844df4358415c48176970a5e6c325ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Thu, 30 Oct 2025 13:10:00 +0100 Subject: [PATCH 009/555] CloudWatch: Add tracking for logs anomalies (#113181) - Fix tracking for new logs queries with logGroups field --- .../mocks/dashboardOnLoadedEvent.ts | 30 ++++++++++++++++++- .../datasource/cloudwatch/tracking.test.ts | 5 ++-- .../plugins/datasource/cloudwatch/tracking.ts | 27 ++++++++++++----- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/public/app/plugins/datasource/cloudwatch/mocks/dashboardOnLoadedEvent.ts b/public/app/plugins/datasource/cloudwatch/mocks/dashboardOnLoadedEvent.ts index ebe3dcaf1f0..f2095b5c4f2 100644 --- a/public/app/plugins/datasource/cloudwatch/mocks/dashboardOnLoadedEvent.ts +++ b/public/app/plugins/datasource/cloudwatch/mocks/dashboardOnLoadedEvent.ts @@ -2,7 +2,7 @@ import { DashboardLoadedEvent } from '@grafana/data'; import { CloudWatchQuery } from '../types'; -const baseLogsQuery = { +const baseLegacyLogsQuery = { datasource: { type: 'cloudwatch', uid: 'P7DC3E4760CFAC4AP', @@ -17,6 +17,15 @@ const baseLogsQuery = { statsGroups: [], }; +const baseLogsQuery = { + ...baseLegacyLogsQuery, + logGroups: [ + { arn: 'arn:test', name: 'log-group-1' }, + { arn: 'arn:test2', name: 'log-group-2' }, + ], + logGroupNames: undefined, +}; + export const CloudWatchDashboardLoadedEvent = new DashboardLoadedEvent({ dashboardId: 'dashboard123', orgId: 1, @@ -474,6 +483,9 @@ export const CloudWatchDashboardLoadedEvent = new DashboardLoadedEvent({ sqlExpression: '', statistic: 'Average', }, + { + ...baseLegacyLogsQuery, + }, { ...baseLogsQuery, }, @@ -779,6 +791,22 @@ export const CloudWatchDashboardLoadedEvent = new DashboardLoadedEvent({ sqlExpression: '', statistic: '', }, + { + refId: 'A', + region: 'default', + queryMode: 'Logs', + logsMode: 'Anomalies', + anomalyDetectionARN: '', + suppressionState: 'suppressed', + }, + { + refId: 'A', + region: 'default', + queryMode: 'Logs', + logsMode: 'Anomalies', + anomalyDetectionARN: '', + suppressionState: 'all', + }, ] as CloudWatchQuery[], }, }); diff --git a/public/app/plugins/datasource/cloudwatch/tracking.test.ts b/public/app/plugins/datasource/cloudwatch/tracking.test.ts index 822cf07a5d1..f66f0a2ad25 100644 --- a/public/app/plugins/datasource/cloudwatch/tracking.test.ts +++ b/public/app/plugins/datasource/cloudwatch/tracking.test.ts @@ -27,10 +27,11 @@ describe('onDashboardLoadedHandler', () => { dashboard_id: 'dashboard123', grafana_version: 'v9.0.0', org_id: 1, - logs_queries_count: 5, - logs_cwli_queries_count: 2, + logs_queries_count: 8, + logs_cwli_queries_count: 3, logs_sql_queries_count: 1, logs_ppl_queries_count: 2, + log_anomalies_queries_count: 2, metrics_queries_count: 21, metrics_query_builder_count: 3, metrics_query_code_count: 4, diff --git a/public/app/plugins/datasource/cloudwatch/tracking.ts b/public/app/plugins/datasource/cloudwatch/tracking.ts index 4bf91191f1e..51a4559ee1e 100644 --- a/public/app/plugins/datasource/cloudwatch/tracking.ts +++ b/public/app/plugins/datasource/cloudwatch/tracking.ts @@ -1,13 +1,15 @@ import { DashboardLoadedEvent } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from './guards'; +import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery, isLogsAnomaliesQuery } from './guards'; import { migrateMetricQuery } from './migrations/metricQueryMigrations'; import pluginJson from './plugin.json'; import { + CloudWatchLogsAnomaliesQuery, CloudWatchLogsQuery, CloudWatchMetricsQuery, CloudWatchQuery, + LogsMode, LogsQueryLanguage, MetricEditorMode, MetricQueryType, @@ -31,6 +33,9 @@ type CloudWatchOnDashboardLoadedTrackingEvent = { /* The number of Logs queries that use PPL language */ logs_ppl_queries_count: number; + /* The number of log anomalies queries */ + log_anomalies_queries_count: number; + /* The number of CloudWatch metrics queries present in the dashboard*/ metrics_queries_count: number; @@ -77,7 +82,8 @@ export const onDashboardLoadedHandler = ({ return; } - let logsQueries: CloudWatchLogsQuery[] = []; + let logsInsightsQueries: CloudWatchLogsQuery[] = []; + let logAnomaliesQueries: CloudWatchLogsAnomaliesQuery[] = []; let metricsQueries: CloudWatchMetricsQuery[] = []; for (const query of cloudWatchQueries) { @@ -85,8 +91,12 @@ export const onDashboardLoadedHandler = ({ continue; } - if (isCloudWatchLogsQuery(query)) { - query.logGroupNames?.length && logsQueries.push(query); + const isLogsInsightsQuery = + isCloudWatchLogsQuery(query) && (!query.logsMode || query.logsMode === LogsMode.Insights); + if (isLogsInsightsQuery) { + (query.logGroupNames?.length || query.logGroups?.length) && logsInsightsQueries.push(query); + } else if (isLogsAnomaliesQuery(query)) { + logAnomaliesQueries.push(query); } else if (isCloudWatchMetricsQuery(query)) { const migratedQuery = migrateMetricQuery(query); filterMetricsQuery(migratedQuery) && metricsQueries.push(query); @@ -97,12 +107,13 @@ export const onDashboardLoadedHandler = ({ grafana_version: grafanaVersion, dashboard_id: dashboardId, org_id: orgId, - logs_queries_count: logsQueries?.length, - logs_cwli_queries_count: logsQueries?.filter( + logs_queries_count: logsInsightsQueries?.length + logAnomaliesQueries.length, + logs_cwli_queries_count: logsInsightsQueries?.filter( (q) => !q.queryLanguage || q.queryLanguage === LogsQueryLanguage.CWLI ).length, - logs_sql_queries_count: logsQueries?.filter((q) => q.queryLanguage === LogsQueryLanguage.SQL).length, - logs_ppl_queries_count: logsQueries?.filter((q) => q.queryLanguage === LogsQueryLanguage.PPL).length, + logs_sql_queries_count: logsInsightsQueries?.filter((q) => q.queryLanguage === LogsQueryLanguage.SQL).length, + logs_ppl_queries_count: logsInsightsQueries?.filter((q) => q.queryLanguage === LogsQueryLanguage.PPL).length, + log_anomalies_queries_count: logAnomaliesQueries.length, metrics_queries_count: metricsQueries?.length, metrics_search_count: 0, metrics_search_builder_count: 0, From f185377c68bb2f0a943e9974034ba00d04d6511f Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Thu, 30 Oct 2025 09:29:40 -0300 Subject: [PATCH 010/555] ShortURL: Use RTK api for creation (#113185) --- public/app/core/utils/shortLinks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index c01988d2262..011b5c66baa 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -1,10 +1,10 @@ import memoizeOne from 'memoize-one'; -import { generatedAPI } from '@grafana/api-clients/rtkq/shorturl/v1alpha1'; import { AbsoluteTimeRange, LogRowModel, UrlQueryMap } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; +import { shortURLAPIv1alpha1 } from 'app/api/clients/shorturl/v1alpha1'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; @@ -46,7 +46,7 @@ export const createShortLink = async function (path: string) { if (config.featureToggles.useKubernetesShortURLsAPI) { // Use RTK API - it handles caching/failures/retries automatically const result = await dispatch( - generatedAPI.endpoints.createShortUrl.initiate({ + shortURLAPIv1alpha1.endpoints.createShortUrl.initiate({ shortUrl: { apiVersion: 'shorturl.grafana.app/v1alpha1', kind: 'ShortURL', From d303746ff90563669cfbeae7f6d1041770c7bec6 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 30 Oct 2025 15:57:03 +0300 Subject: [PATCH 011/555] ShortURL: Use UpdateStatus client (#111170) --- apps/shorturl/pkg/app/app.go | 14 ++- pkg/api/short_url_test.go | 9 +- pkg/registry/apps/shorturl/conversions.go | 9 +- pkg/registry/apps/shorturl/legacy_storage.go | 52 +------- pkg/registry/apps/shorturl/register.go | 16 +++ pkg/registry/apps/shorturl/status.go | 111 ++++++++++++++++++ .../apiserver/appinstaller/installer.go | 10 +- pkg/services/apiserver/appinstaller/server.go | 52 +++++--- pkg/services/shorturls/shorturl.go | 6 +- .../shorturls/shorturlimpl/shorturl.go | 10 +- pkg/services/shorturls/shorturlimpl/store.go | 8 +- pkg/tests/apis/shorturl/shorturl_test.go | 83 ++++++------- 12 files changed, 250 insertions(+), 130 deletions(-) create mode 100644 pkg/registry/apps/shorturl/status.go diff --git a/apps/shorturl/pkg/app/app.go b/apps/shorturl/pkg/app/app.go index 4c85ef97435..bb97db78a93 100644 --- a/apps/shorturl/pkg/app/app.go +++ b/apps/shorturl/pkg/app/app.go @@ -30,11 +30,12 @@ var ( func New(cfg app.Config) (app.App, error) { cfg.KubeConfig.APIPath = "apis" - client, err := k8s.NewClientRegistry(cfg.KubeConfig, k8s.DefaultClientConfig()). + tmp, err := k8s.NewClientRegistry(cfg.KubeConfig, k8s.DefaultClientConfig()). ClientFor(shorturlv1alpha1.ShortURLKind()) if err != nil { return nil, fmt.Errorf("unable to create client") } + client := shorturlv1alpha1.NewShortURLClient(tmp) simpleConfig := simple.AppConfig{ Name: "shorturl", @@ -81,8 +82,8 @@ func New(cfg app.Config) (app.App, error) { Name: req.ResourceIdentifier.Name, } - info := &shorturlv1alpha1.ShortURL{} - if err := client.GetInto(ctx, id, info); err != nil { + info, err := client.Get(ctx, id) + if err != nil { return err } @@ -93,7 +94,12 @@ func New(cfg app.Config) (app.App, error) { if err != nil { logging.FromContext(ctx).Warn("unable to create background identity", "err", err) } else { - _, _ = client.Update(ctx, id, info, resource.UpdateOptions{}) + _, err = client.UpdateStatus(ctx, id, info.Status, resource.UpdateOptions{ + ResourceVersion: info.ResourceVersion, + }) + if err != nil { + logging.FromContext(ctx).Warn("unable to update status", "err", err) + } } }() diff --git a/pkg/api/short_url_test.go b/pkg/api/short_url_test.go index 6d3da5fbe0c..116d4f70328 100644 --- a/pkg/api/short_url_test.go +++ b/pkg/api/short_url_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/shorturls" @@ -31,7 +32,7 @@ func TestShortURLAPIEndpoint(t *testing.T) { Path: cmd.Path, } service := &fakeShortURLService{ - createShortURLFunc: func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { + createShortURLFunc: func(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { return createResp, nil }, createConvertShortURLToDTO: func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL { @@ -81,7 +82,7 @@ func createShortURLScenario(t *testing.T, desc string, url string, routePattern } type fakeShortURLService struct { - createShortURLFunc func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) + createShortURLFunc func(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) createConvertShortURLToDTO func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL } @@ -89,11 +90,11 @@ func (s *fakeShortURLService) List(ctx context.Context, orgID int64) ([]*shortur return nil, nil } -func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) { +func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error) { return nil, nil } -func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { +func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { if s.createShortURLFunc != nil { return s.createShortURLFunc(ctx, user, cmd) } diff --git a/pkg/registry/apps/shorturl/conversions.go b/pkg/registry/apps/shorturl/conversions.go index 979106d3bda..c23f014adbd 100644 --- a/pkg/registry/apps/shorturl/conversions.go +++ b/pkg/registry/apps/shorturl/conversions.go @@ -21,10 +21,17 @@ func convertToK8sResource(v *shorturls.ShortUrl, namespacer request.NamespaceMap status := shorturl.ShortURLStatus{ LastSeenAt: v.LastSeenAt, } + + // resourceVersion can't be 0, since we are using the lastSeenAt value, when it's zero we default to current time + resourceVersion := fmt.Sprintf("%d", v.LastSeenAt) + if v.LastSeenAt == 0 { + resourceVersion = fmt.Sprintf("%d", time.Now().UnixMilli()) + } + p := &shorturl.ShortURL{ ObjectMeta: metav1.ObjectMeta{ Name: v.Uid, - ResourceVersion: fmt.Sprintf("%d", v.LastSeenAt), + ResourceVersion: resourceVersion, CreationTimestamp: metav1.NewTime(time.UnixMilli(v.CreatedAt)), Namespace: namespacer(v.OrgId), }, diff --git a/pkg/registry/apps/shorturl/legacy_storage.go b/pkg/registry/apps/shorturl/legacy_storage.go index c9462359236..de36560b679 100644 --- a/pkg/registry/apps/shorturl/legacy_storage.go +++ b/pkg/registry/apps/shorturl/legacy_storage.go @@ -16,9 +16,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/shorturls" - "github.com/grafana/grafana/pkg/services/user" ) var ( @@ -87,13 +85,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, err } - // Convert any identity.Requester to *user.SignedInUser - signedInUser, err := convertRequesterToSignedInUser(requester) - if err != nil { - return nil, fmt.Errorf("failed to convert requester: %w", err) - } - - dto, err := s.service.GetShortURLByUID(ctx, signedInUser, name) + dto, err := s.service.GetShortURLByUID(ctx, requester, name) if err != nil || dto == nil { if errors.Is(err, shorturls.ErrShortURLNotFound) || err == nil { err = k8serrors.NewNotFound(shorturl.ShortURLKind().GroupVersionResource().GroupResource(), name) @@ -114,12 +106,6 @@ func (s *legacyStorage) Create(ctx context.Context, return nil, err } - // Convert any identity.Requester to *user.SignedInUser - signedInUser, err := convertRequesterToSignedInUser(requester) - if err != nil { - return nil, fmt.Errorf("failed to convert requester: %w", err) - } - if createValidation != nil { if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { return nil, err @@ -133,7 +119,7 @@ func (s *legacyStorage) Create(ctx context.Context, Path: p.Spec.Path, UID: p.Name, } - out, err := s.service.CreateShortURL(ctx, signedInUser, cmd) + out, err := s.service.CreateShortURL(ctx, requester, cmd) if err != nil { return nil, err } @@ -154,13 +140,7 @@ func (s *legacyStorage) Update(ctx context.Context, return nil, false, err } - // Convert any identity.Requester to *user.SignedInUser - signedInUser, err := convertRequesterToSignedInUser(requester) - if err != nil { - return nil, false, fmt.Errorf("failed to convert requester: %w", err) - } - - shortURL, err := s.service.GetShortURLByUID(ctx, signedInUser, name) + shortURL, err := s.service.GetShortURLByUID(ctx, requester, name) if err != nil || shortURL == nil { if errors.Is(err, shorturls.ErrShortURLNotFound) || err == nil { err = k8serrors.NewNotFound(shorturl.ShortURLKind().GroupVersionResource().GroupResource(), name) @@ -173,7 +153,7 @@ func (s *legacyStorage) Update(ctx context.Context, return nil, false, err } // Fetch the updated short URL to return - updatedLegacyShortURL, err := s.service.GetShortURLByUID(ctx, signedInUser, name) + updatedLegacyShortURL, err := s.service.GetShortURLByUID(ctx, requester, name) if err != nil { return nil, false, err } @@ -199,27 +179,3 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { return nil, fmt.Errorf("DeleteCollection for shorturl not implemented") } - -// convertRequesterToSignedInUser converts any identity.Requester to *user.SignedInUser -// This is needed because some legacy shorturls service methods still expect SignedInUser -func convertRequesterToSignedInUser(requester identity.Requester) (*user.SignedInUser, error) { - // If it's already a SignedInUser, return it directly - if signedInUser, ok := requester.(*user.SignedInUser); ok { - return signedInUser, nil - } - - // If it's a StaticRequester (service identity), convert it - if staticRequester, ok := requester.(*identity.StaticRequester); ok { - return &user.SignedInUser{ - UserID: staticRequester.UserID, // Used for CreatedBy field - OrgID: staticRequester.OrgID, // Used in SQL queries - }, nil - } - - // If it's an authn.Identity, use its SignedInUser method - if authnIdentity, ok := requester.(*authn.Identity); ok { - return authnIdentity.SignedInUser(), nil - } - - return nil, fmt.Errorf("unsupported identity type") -} diff --git a/pkg/registry/apps/shorturl/register.go b/pkg/registry/apps/shorturl/register.go index a44593ceabe..423eae92073 100644 --- a/pkg/registry/apps/shorturl/register.go +++ b/pkg/registry/apps/shorturl/register.go @@ -5,6 +5,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/registry/rest" restclient "k8s.io/client-go/rest" "github.com/grafana/grafana-app-sdk/app" @@ -86,3 +87,18 @@ func (s *ShortURLAppInstaller) GetLegacyStorage(requested schema.GroupVersionRes ) return legacyStore } + +func (s *ShortURLAppInstaller) GetLegacyStatus(requested schema.GroupVersionResource, unified *appsdkapiserver.StatusREST) rest.Storage { + gvr := shorturl.ShortURLKind().GroupVersionResource() + if requested.String() != gvr.String() { + return nil + } + return &statusDualWriter{ + gv: gvr.GroupVersion(), + status: unified, + legacy: &legacyStorage{ + service: s.service, + namespacer: s.namespacer, + }, + } +} diff --git a/pkg/registry/apps/shorturl/status.go b/pkg/registry/apps/shorturl/status.go new file mode 100644 index 00000000000..ed9fe063a98 --- /dev/null +++ b/pkg/registry/apps/shorturl/status.go @@ -0,0 +1,111 @@ +package shorturl + +import ( + "context" + "errors" + "fmt" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/registry/rest" + "sigs.k8s.io/structured-merge-diff/v6/fieldpath" + + "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "github.com/grafana/grafana-app-sdk/logging" + shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/shorturls" +) + +type statusDualWriter struct { + gv schema.GroupVersion + status *apiserver.StatusREST + legacy *legacyStorage +} + +var ( + _ rest.Patcher = (*statusDualWriter)(nil) + _ rest.Storage = (*statusDualWriter)(nil) + _ rest.ResetFieldsStrategy = (*statusDualWriter)(nil) +) + +// Destroy implements rest.Storage. +func (s *statusDualWriter) Destroy() {} + +// New implements rest.Storage. +func (s *statusDualWriter) New() runtime.Object { + return s.legacy.New() +} + +// Get implements rest.Patcher. +func (s *statusDualWriter) Get(ctx context.Context, name string, options *v1.GetOptions) (runtime.Object, error) { + return s.legacy.Get(ctx, name, options) +} + +// Update implements rest.Patcher. +func (s *statusDualWriter) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *v1.UpdateOptions) (runtime.Object, bool, error) { + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, false, err + } + + shortURL, err := s.legacy.service.GetShortURLByUID(ctx, requester, name) + if err != nil || shortURL == nil { + if errors.Is(err, shorturls.ErrShortURLNotFound) || err == nil { + err = k8serrors.NewNotFound(shorturl.ShortURLKind().GroupVersionResource().GroupResource(), name) + } + return nil, false, err + } + + // This ignores the incoming and updates it directly + err = s.legacy.service.UpdateLastSeenAt(ctx, shortURL) + if err != nil { + return nil, false, err + } + + getter := func(getter rest.Getter) (*shorturl.ShortURL, error) { + obj, err := getter.Get(ctx, name, &v1.GetOptions{}) + if err != nil { + return nil, err + } + val, ok := obj.(*shorturl.ShortURL) + if !ok { + return nil, fmt.Errorf("expected ShortURL but got %T", obj) + } + return val, nil + } + + legacy, err := getter(s.legacy) + if err != nil { + return nil, false, err // unable to get legacy object + } + + unified, err := getter(s.status) + if err != nil { + logging.FromContext(ctx).Warn("unable to read unified status", "error", err) + return legacy, false, nil + } + + // Use the same status from legacy in unified + unified.Status = legacy.Status + + _, _, err = s.status.Update(ctx, name, rest.DefaultUpdatedObjectInfo(unified), createValidation, updateValidation, false, options) + if err != nil { + logging.FromContext(ctx).Warn("error updating unified status", "error", err) + } + + return legacy, false, err +} + +// GetResetFields implements rest.ResetFieldsStrategy +func (s *statusDualWriter) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set { + fields := map[fieldpath.APIVersion]*fieldpath.Set{ + fieldpath.APIVersion(s.gv.String()): fieldpath.NewSet( + fieldpath.MakePathOrDie("spec"), + fieldpath.MakePathOrDie("metadata"), + ), + } + return fields +} diff --git a/pkg/services/apiserver/appinstaller/installer.go b/pkg/services/apiserver/appinstaller/installer.go index a89d2b84f9a..d7742eedf12 100644 --- a/pkg/services/apiserver/appinstaller/installer.go +++ b/pkg/services/apiserver/appinstaller/installer.go @@ -12,18 +12,18 @@ import ( "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" serverstore "k8s.io/apiserver/pkg/server/storage" "k8s.io/kube-openapi/pkg/common" appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver" "github.com/grafana/grafana-app-sdk/logging" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) type LegacyStorageGetterFunc func(schema.GroupVersionResource) grafanarest.Storage @@ -32,6 +32,12 @@ type LegacyStorageProvider interface { GetLegacyStorage(schema.GroupVersionResource) grafanarest.Storage } +// In the rare case that that legacy needs to support the status subresource +// Unlike resource storage, dual writing must be managed explicitly +type LegacyStatusProvider interface { + GetLegacyStatus(schema.GroupVersionResource, *appsdkapiserver.StatusREST) rest.Storage +} + type AuthorizerProvider interface { GetAuthorizer() authorizer.Authorizer } diff --git a/pkg/services/apiserver/appinstaller/server.go b/pkg/services/apiserver/appinstaller/server.go index 2e5d46b2b3d..ab688a46fb9 100644 --- a/pkg/services/apiserver/appinstaller/server.go +++ b/pkg/services/apiserver/appinstaller/server.go @@ -60,25 +60,41 @@ func (s *serverWrapper) InstallAPIGroup(apiGroupInfo *genericapiserver.APIGroupI continue } storage := s.configureStorage(gr, dualWriteSupported, restStorage) - if unifiedStorage, ok := storage.(grafanarest.Storage); ok && dualWriteSupported { - log.Debug("Configuring dual writer for storage", "resource", gr.String(), "version", v, "storagePath", storagePath) - dw, err := NewDualWriter( - s.ctx, - gr, - s.storageOpts, - legacyProvider.GetLegacyStorage(gr.WithVersion(v)), - unifiedStorage, - s.kvStore, - s.lock, - s.namespaceMapper, - s.dualWriteService, - s.dualWriterMetrics, - s.builderMetrics, - ) - if err != nil { - return err + if dualWriteSupported { + if unifiedStorage, ok := storage.(grafanarest.Storage); ok { + log.Debug("Configuring dual writer for storage", "resource", gr.String(), "version", v, "storagePath", storagePath) + storage, err = NewDualWriter( + s.ctx, + gr, + s.storageOpts, + legacyProvider.GetLegacyStorage(gr.WithVersion(v)), + unifiedStorage, + s.kvStore, + s.lock, + s.namespaceMapper, + s.dualWriteService, + s.dualWriterMetrics, + s.builderMetrics, + ) + if err != nil { + return err + } + } else if statusRest, ok := storage.(*appsdkapiserver.StatusREST); ok { + parentPath := strings.TrimSuffix(storagePath, "/status") + parentStore, ok := apiGroupInfo.VersionedResourcesStorageMap[v][parentPath] + if ok { + if _, isMode4or5 := parentStore.(*genericregistry.Store); !isMode4or5 { + // When legacy resources have status, the dual writing must be handled explicitly + if statusProvider, ok := s.installer.(LegacyStatusProvider); ok { + storage = statusProvider.GetLegacyStatus(gr.WithVersion(v), statusRest) + } else { + log.Warn("skipped registering status sub-resource that does not support dual writing", + "resource", gr.String(), "version", v, "storagePath", storagePath) + continue + } + } + } } - storage = dw } apiGroupInfo.VersionedResourcesStorageMap[v][storagePath] = storage } diff --git a/pkg/services/shorturls/shorturl.go b/pkg/services/shorturls/shorturl.go index d62f469f85c..99040011f50 100644 --- a/pkg/services/shorturls/shorturl.go +++ b/pkg/services/shorturls/shorturl.go @@ -4,12 +4,12 @@ import ( "context" "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) type Service interface { - GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*ShortUrl, error) - CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*ShortUrl, error) + GetShortURLByUID(ctx context.Context, user identity.Requester, uid string) (*ShortUrl, error) + CreateShortURL(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*ShortUrl, error) UpdateLastSeenAt(ctx context.Context, shortURL *ShortUrl) error DeleteStaleShortURLs(ctx context.Context, cmd *DeleteShortUrlCommand) error ConvertShortURLToDTO(shortURL *ShortUrl, appURL string) *dtos.ShortURL diff --git a/pkg/services/shorturls/shorturlimpl/shorturl.go b/pkg/services/shorturls/shorturlimpl/shorturl.go index 51753b74c6e..a363a4d7627 100644 --- a/pkg/services/shorturls/shorturlimpl/shorturl.go +++ b/pkg/services/shorturls/shorturlimpl/shorturl.go @@ -8,9 +8,9 @@ import ( "time" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/shorturls" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" ) @@ -28,7 +28,7 @@ func ProvideService(db db.DB) *ShortURLService { } } -func (s ShortURLService) GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) { +func (s ShortURLService) GetShortURLByUID(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error) { return s.SQLStore.Get(ctx, user, uid) } @@ -40,7 +40,7 @@ func (s ShortURLService) List(ctx context.Context, orgID int64) ([]*shorturls.Sh return s.SQLStore.List(ctx, orgID) } -func (s ShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { +func (s ShortURLService) CreateShortURL(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { relPath := strings.TrimSpace(cmd.Path) if path.IsAbs(relPath) { @@ -74,12 +74,12 @@ func (s ShortURLService) CreateShortURL(ctx context.Context, user *user.SignedIn now := time.Now().Unix() shortURL := shorturls.ShortUrl{ - OrgId: user.OrgID, + OrgId: user.GetOrgID(), Uid: uid, Path: relPath, - CreatedBy: user.UserID, CreatedAt: now, } + shortURL.CreatedBy, _ = user.GetInternalID() if err := s.SQLStore.Insert(ctx, &shortURL); err != nil { return nil, shorturls.ErrShortURLInternal.Errorf("failed to insert shorturl: %w", err) diff --git a/pkg/services/shorturls/shorturlimpl/store.go b/pkg/services/shorturls/shorturlimpl/store.go index 6e0a072d23c..ad74b269d45 100644 --- a/pkg/services/shorturls/shorturlimpl/store.go +++ b/pkg/services/shorturls/shorturlimpl/store.go @@ -3,13 +3,13 @@ package shorturlimpl import ( "context" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/shorturls" - "github.com/grafana/grafana/pkg/services/user" ) type store interface { - Get(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) + Get(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error) Update(ctx context.Context, shortURL *shorturls.ShortUrl) error Insert(ctx context.Context, shortURL *shorturls.ShortUrl) error Delete(ctx context.Context, cmd *shorturls.DeleteShortUrlCommand) error @@ -20,10 +20,10 @@ type sqlStore struct { db db.DB } -func (s sqlStore) Get(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) { +func (s sqlStore) Get(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error) { var shortURL shorturls.ShortUrl err := s.db.WithDbSession(ctx, func(dbSession *db.Session) error { - exists, err := dbSession.Where("org_id=? AND uid=?", user.OrgID, uid).Get(&shortURL) + exists, err := dbSession.Where("org_id=? AND uid=?", user.GetOrgID(), uid).Get(&shortURL) if err != nil { return err } diff --git a/pkg/tests/apis/shorturl/shorturl_test.go b/pkg/tests/apis/shorturl/shorturl_test.go index 86445846acf..1b6f44dd7e4 100644 --- a/pkg/tests/apis/shorturl/shorturl_test.go +++ b/pkg/tests/apis/shorturl/shorturl_test.go @@ -5,13 +5,14 @@ import ( "fmt" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" + shorturlV1 "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" "github.com/grafana/grafana/pkg/api/dtos" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/options" @@ -28,11 +29,7 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -var gvr = schema.GroupVersionResource{ - Group: "shorturl.grafana.app", - Version: "v1alpha1", - Resource: "shorturls", -} +var gvr = shorturlV1.ShortURLKind().GroupVersionResource() var RESOURCEGROUP = gvr.GroupResource().String() @@ -78,29 +75,31 @@ func TestIntegrationShortURL(t *testing.T) { doLegacyOnlyTests(t, helper) }) - for _, mode := range []grafanarest.DualWriterMode{ - grafanarest.Mode1, - grafanarest.Mode2, - // grafanarest.Mode3, TODO: the /goto function needs to use an UpdateStatus client - // grafanarest.Mode4, - } { - t.Run(fmt.Sprintf("with dual write (unified storage, mode %d)", mode), func(t *testing.T) { - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: false, - DisableAnonymous: true, - APIServerStorageType: options.StorageTypeUnified, - EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesShortURLs, - }, - UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - RESOURCEGROUP: { - DualWriterMode: mode, + t.Run("modes", func(t *testing.T) { + for _, mode := range []grafanarest.DualWriterMode{ + grafanarest.Mode1, + grafanarest.Mode2, + grafanarest.Mode3, + grafanarest.Mode4, + } { + t.Run(fmt.Sprintf("dual write (unified storage, mode %d)", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: options.StorageTypeUnified, + EnableFeatureToggles: []string{ + featuremgmt.FlagKubernetesShortURLs, }, - }, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: mode, + }, + }, + }) + doDualWriteTests(t, helper, mode) }) - doDualWriteTests(t, helper, mode) - }) - } + } + }) t.Run("with dual write (unified storage, mode 5)", func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ @@ -173,7 +172,7 @@ func doLegacyOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { User: client.Args.User, Method: http.MethodGet, Path: "/goto/" + uid + "?orgId=default", - }, (*interface{})(nil)) + }, (*any)(nil)) assert.Equal(t, 302, redirectResponse.Response.StatusCode) }) } @@ -278,21 +277,23 @@ func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest User: client.Args.User, Method: http.MethodGet, Path: "/goto/" + uid + "?orgId=default", - }, (*interface{})(nil)) + }, (*any)(nil)) assert.Equal(t, 302, redirectResponse.Response.StatusCode) - // Verify lastSeenAt was updated (should be > 0 now) - found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) - require.NoError(t, err) - status, exists := found.Object["status"].(map[string]interface{}) - assert.True(t, exists) - lastSeenAt, exists := status["lastSeenAt"].(int64) - assert.True(t, exists) + require.EventuallyWithT(t, func(t *assert.CollectT) { + // Verify lastSeenAt was updated (should be > 0 now) + found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) - assert.Greater(t, lastSeenAt, int64(0)) + lastSeenAt, exists, err := unstructured.NestedInt64(found.Object, "status", "lastSeenAt") + require.NoError(t, err) + require.True(t, exists) + + require.Greater(t, lastSeenAt, int64(1), "lastSeenAt should be greater than 1 after redirect") + }, time.Second*5, time.Millisecond*75, "lastSeenAt should be updated after redirect") // Clean up - err = client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + err := client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) require.NoError(t, err) }) } @@ -458,7 +459,7 @@ func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { User: client.Args.User, Method: http.MethodGet, Path: "/goto/" + uid + "?orgId=default", - }, (*interface{})(nil)) + }, (*any)(nil)) assert.Equal(t, 302, redirectResponse.Response.StatusCode) // Clean up @@ -505,9 +506,9 @@ func getFromBothAPIs(t *testing.T, if legacyShortURL != nil { // If legacy API returns data, verify consistency - spec, ok := k8sResource.Object["spec"].(map[string]interface{}) + spec, ok := k8sResource.Object["spec"].(map[string]any) require.True(t, ok) - status, ok := k8sResource.Object["status"].(map[string]interface{}) + status, ok := k8sResource.Object["status"].(map[string]any) require.True(t, ok) assert.Equal(t, legacyShortURL.Uid, k8sResource.GetName()) assert.Equal(t, legacyShortURL.Path, spec["path"].(string)) From 05dc9b2be1cf5a9dfd06564403175b6417f18d50 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 30 Oct 2025 13:08:14 +0000 Subject: [PATCH 012/555] API Clients: Add lazy hooks to clients (#113226) --- .../rtkq/advisor/v0alpha1/endpoints.gen.ts | 7 + .../correlations/v0alpha1/endpoints.gen.ts | 3 + .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 8 + .../rtkq/folder/v1beta1/endpoints.gen.ts | 7 + .../rtkq/iam/v0alpha1/endpoints.gen.ts | 15 ++ .../src/clients/rtkq/legacy/endpoints.gen.ts | 143 ++++++++++++++++++ .../rtkq/migrate-to-cloud/endpoints.gen.ts | 8 + .../rtkq/playlist/v0alpha1/endpoints.gen.ts | 4 + .../rtkq/preferences/user/endpoints.gen.ts | 8 +- .../preferences/v1alpha1/endpoints.gen.ts | 6 + .../provisioning/v0alpha1/endpoints.gen.ts | 18 +++ .../rtkq/shorturl/v1alpha1/endpoints.gen.ts | 5 + .../src/scripts/generate-rtk-apis.ts | 16 +- 13 files changed, 241 insertions(+), 7 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts index 8cf2fec2b60..4b4de61be87 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/advisor/v0alpha1/endpoints.gen.ts @@ -1008,24 +1008,31 @@ export type CheckTypeList = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListCheckQuery, + useLazyListCheckQuery, useCreateCheckMutation, useDeletecollectionCheckMutation, useGetCheckQuery, + useLazyGetCheckQuery, useReplaceCheckMutation, useDeleteCheckMutation, useUpdateCheckMutation, useGetCheckStatusQuery, + useLazyGetCheckStatusQuery, useReplaceCheckStatusMutation, useUpdateCheckStatusMutation, useListCheckTypeQuery, + useLazyListCheckTypeQuery, useCreateCheckTypeMutation, useDeletecollectionCheckTypeMutation, useGetCheckTypeQuery, + useLazyGetCheckTypeQuery, useReplaceCheckTypeMutation, useDeleteCheckTypeMutation, useUpdateCheckTypeMutation, useGetCheckTypeStatusQuery, + useLazyGetCheckTypeStatusQuery, useReplaceCheckTypeStatusMutation, useUpdateCheckTypeStatusMutation, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts index 9f40ec322a5..6cfab61cdba 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/correlations/v0alpha1/endpoints.gen.ts @@ -511,10 +511,13 @@ export type Status = { export type Patch = object; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListCorrelationQuery, + useLazyListCorrelationQuery, useCreateCorrelationMutation, useDeletecollectionCorrelationMutation, useGetCorrelationQuery, + useLazyGetCorrelationQuery, useReplaceCorrelationMutation, useDeleteCorrelationMutation, useUpdateCorrelationMutation, diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 9e4aee6b516..6ca3505d067 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -1061,21 +1061,29 @@ export type SearchResults = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListDashboardQuery, + useLazyListDashboardQuery, useCreateDashboardMutation, useDeletecollectionDashboardMutation, useGetDashboardQuery, + useLazyGetDashboardQuery, useReplaceDashboardMutation, useDeleteDashboardMutation, useUpdateDashboardMutation, useGetDashboardDtoQuery, + useLazyGetDashboardDtoQuery, useListLibraryPanelQuery, + useLazyListLibraryPanelQuery, useCreateLibraryPanelMutation, useDeletecollectionLibraryPanelMutation, useGetLibraryPanelQuery, + useLazyGetLibraryPanelQuery, useReplaceLibraryPanelMutation, useDeleteLibraryPanelMutation, useUpdateLibraryPanelMutation, useGetSearchQuery, + useLazyGetSearchQuery, useGetSearchSortableQuery, + useLazyGetSearchSortableQuery, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts index b618f8b335a..9ef9e4ebfab 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/folder/v1beta1/endpoints.gen.ts @@ -562,15 +562,22 @@ export type FolderInfoList = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListFolderQuery, + useLazyListFolderQuery, useCreateFolderMutation, useDeletecollectionFolderMutation, useGetFolderQuery, + useLazyGetFolderQuery, useReplaceFolderMutation, useDeleteFolderMutation, useUpdateFolderMutation, useGetFolderAccessQuery, + useLazyGetFolderAccessQuery, useGetFolderChildrenQuery, + useLazyGetFolderChildrenQuery, useGetFolderCountsQuery, + useLazyGetFolderCountsQuery, useGetFolderParentsQuery, + useLazyGetFolderParentsQuery, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts index bd1585fc928..3645b4b3373 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts @@ -1733,41 +1733,56 @@ export type UserTeamList = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useGetDisplayMappingQuery, + useLazyGetDisplayMappingQuery, useListServiceAccountQuery, + useLazyListServiceAccountQuery, useCreateServiceAccountMutation, useDeletecollectionServiceAccountMutation, useGetServiceAccountQuery, + useLazyGetServiceAccountQuery, useReplaceServiceAccountMutation, useDeleteServiceAccountMutation, useUpdateServiceAccountMutation, useGetServiceAccountTokensQuery, + useLazyGetServiceAccountTokensQuery, useListSsoSettingQuery, + useLazyListSsoSettingQuery, useGetSsoSettingQuery, + useLazyGetSsoSettingQuery, useReplaceSsoSettingMutation, useDeleteSsoSettingMutation, useUpdateSsoSettingMutation, useListTeamBindingQuery, + useLazyListTeamBindingQuery, useCreateTeamBindingMutation, useDeletecollectionTeamBindingMutation, useGetTeamBindingQuery, + useLazyGetTeamBindingQuery, useReplaceTeamBindingMutation, useDeleteTeamBindingMutation, useUpdateTeamBindingMutation, useListTeamQuery, + useLazyListTeamQuery, useCreateTeamMutation, useDeletecollectionTeamMutation, useGetTeamQuery, + useLazyGetTeamQuery, useReplaceTeamMutation, useDeleteTeamMutation, useUpdateTeamMutation, useGetTeamMembersQuery, + useLazyGetTeamMembersQuery, useListUserQuery, + useLazyListUserQuery, useCreateUserMutation, useDeletecollectionUserMutation, useGetUserQuery, + useLazyGetUserQuery, useReplaceUserMutation, useDeleteUserMutation, useUpdateUserMutation, useGetUserTeamsQuery, + useLazyGetUserTeamsQuery, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index eb201201379..bd6ff32f5bf 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -6502,319 +6502,462 @@ export type NotificationTemplateContent = { export const { useSearchResultMutation, useListRolesQuery, + useLazyListRolesQuery, useCreateRoleMutation, useDeleteRoleMutation, useGetRoleQuery, + useLazyGetRoleQuery, useUpdateRoleMutation, useGetRoleAssignmentsQuery, + useLazyGetRoleAssignmentsQuery, useSetRoleAssignmentsMutation, useGetAccessControlStatusQuery, + useLazyGetAccessControlStatusQuery, useListTeamsRolesMutation, useListTeamRolesQuery, + useLazyListTeamRolesQuery, useAddTeamRoleMutation, useSetTeamRolesMutation, useRemoveTeamRoleMutation, useListUsersRolesMutation, useListUserRolesQuery, + useLazyListUserRolesQuery, useAddUserRoleMutation, useSetUserRolesMutation, useRemoveUserRoleMutation, useGetResourceDescriptionQuery, + useLazyGetResourceDescriptionQuery, useGetResourcePermissionsQuery, + useLazyGetResourcePermissionsQuery, useSetResourcePermissionsMutation, useSetResourcePermissionsForBuiltInRoleMutation, useSetResourcePermissionsForTeamMutation, useSetResourcePermissionsForUserMutation, useGetSyncStatusQuery, + useLazyGetSyncStatusQuery, useReloadLdapCfgMutation, useGetLdapStatusQuery, + useLazyGetLdapStatusQuery, usePostSyncUserWithLdapMutation, useGetUserFromLdapQuery, + useLazyGetUserFromLdapQuery, useAdminProvisioningReloadAccessControlMutation, useAdminProvisioningReloadDashboardsMutation, useAdminProvisioningReloadDatasourcesMutation, useAdminProvisioningReloadPluginsMutation, useAdminGetSettingsQuery, + useLazyAdminGetSettingsQuery, useAdminGetStatsQuery, + useLazyAdminGetStatsQuery, useAdminCreateUserMutation, useAdminDeleteUserMutation, useAdminGetUserAuthTokensQuery, + useLazyAdminGetUserAuthTokensQuery, useAdminDisableUserMutation, useAdminEnableUserMutation, useAdminLogoutUserMutation, useAdminUpdateUserPasswordMutation, useAdminUpdateUserPermissionsMutation, useGetUserQuotaQuery, + useLazyGetUserQuotaQuery, useUpdateUserQuotaMutation, useAdminRevokeUserAuthTokenMutation, useGetAnnotationsQuery, + useLazyGetAnnotationsQuery, usePostAnnotationMutation, usePostGraphiteAnnotationMutation, useMassDeleteAnnotationsMutation, useGetAnnotationTagsQuery, + useLazyGetAnnotationTagsQuery, useDeleteAnnotationByIdMutation, useGetAnnotationByIdQuery, + useLazyGetAnnotationByIdQuery, usePatchAnnotationMutation, useUpdateAnnotationMutation, useListDevicesQuery, + useLazyListDevicesQuery, useSearchDevicesQuery, + useLazySearchDevicesQuery, useGetSessionListQuery, + useLazyGetSessionListQuery, useCreateSessionMutation, useDeleteSessionMutation, useGetSessionQuery, + useLazyGetSessionQuery, useCreateSnapshotMutation, useGetSnapshotQuery, + useLazyGetSnapshotQuery, useCancelSnapshotMutation, useUploadSnapshotMutation, useGetShapshotListQuery, + useLazyGetShapshotListQuery, useGetResourceDependenciesQuery, + useLazyGetResourceDependenciesQuery, useGetCloudMigrationTokenQuery, + useLazyGetCloudMigrationTokenQuery, useCreateCloudMigrationTokenMutation, useDeleteCloudMigrationTokenMutation, useRouteConvertPrometheusCortexGetRulesQuery, + useLazyRouteConvertPrometheusCortexGetRulesQuery, useRouteConvertPrometheusCortexPostRuleGroupsMutation, useRouteConvertPrometheusCortexDeleteNamespaceMutation, useRouteConvertPrometheusCortexGetNamespaceQuery, + useLazyRouteConvertPrometheusCortexGetNamespaceQuery, useRouteConvertPrometheusCortexPostRuleGroupMutation, useRouteConvertPrometheusCortexDeleteRuleGroupMutation, useRouteConvertPrometheusCortexGetRuleGroupQuery, + useLazyRouteConvertPrometheusCortexGetRuleGroupQuery, useRouteConvertPrometheusGetRulesQuery, + useLazyRouteConvertPrometheusGetRulesQuery, useRouteConvertPrometheusPostRuleGroupsMutation, useRouteConvertPrometheusDeleteNamespaceMutation, useRouteConvertPrometheusGetNamespaceQuery, + useLazyRouteConvertPrometheusGetNamespaceQuery, useRouteConvertPrometheusPostRuleGroupMutation, useRouteConvertPrometheusDeleteRuleGroupMutation, useRouteConvertPrometheusGetRuleGroupQuery, + useLazyRouteConvertPrometheusGetRuleGroupQuery, useSearchDashboardSnapshotsQuery, + useLazySearchDashboardSnapshotsQuery, useCalculateDashboardDiffMutation, usePostDashboardMutation, useGetHomeDashboardQuery, + useLazyGetHomeDashboardQuery, useImportDashboardMutation, useInterpolateDashboardMutation, useListPublicDashboardsQuery, + useLazyListPublicDashboardsQuery, useGetDashboardTagsQuery, + useLazyGetDashboardTagsQuery, useGetPublicDashboardQuery, + useLazyGetPublicDashboardQuery, useCreatePublicDashboardMutation, useDeletePublicDashboardMutation, useUpdatePublicDashboardMutation, useDeleteDashboardByUidMutation, useGetDashboardByUidQuery, + useLazyGetDashboardByUidQuery, useGetDashboardPermissionsListByUidQuery, + useLazyGetDashboardPermissionsListByUidQuery, useUpdateDashboardPermissionsByUidMutation, useRestoreDashboardVersionByUidMutation, useGetDashboardVersionsByUidQuery, + useLazyGetDashboardVersionsByUidQuery, useGetDashboardVersionByUidQuery, + useLazyGetDashboardVersionByUidQuery, useGetDataSourcesQuery, + useLazyGetDataSourcesQuery, useAddDataSourceMutation, useGetCorrelationsQuery, + useLazyGetCorrelationsQuery, useGetDataSourceIdByNameQuery, + useLazyGetDataSourceIdByNameQuery, useDeleteDataSourceByNameMutation, useGetDataSourceByNameQuery, + useLazyGetDataSourceByNameQuery, useDatasourceProxyDeleteByUiDcallsMutation, useDatasourceProxyGetByUiDcallsQuery, + useLazyDatasourceProxyGetByUiDcallsQuery, useDatasourceProxyPostByUiDcallsMutation, useGetCorrelationsBySourceUidQuery, + useLazyGetCorrelationsBySourceUidQuery, useCreateCorrelationMutation, useGetCorrelationQuery, + useLazyGetCorrelationQuery, useUpdateCorrelationMutation, useDeleteDataSourceByUidMutation, useGetDataSourceByUidQuery, + useLazyGetDataSourceByUidQuery, useUpdateDataSourceByUidMutation, useDeleteCorrelationMutation, useCheckDatasourceHealthWithUidQuery, + useLazyCheckDatasourceHealthWithUidQuery, useGetTeamLbacRulesApiQuery, + useLazyGetTeamLbacRulesApiQuery, useUpdateTeamLbacRulesApiMutation, useCallDatasourceResourceWithUidQuery, + useLazyCallDatasourceResourceWithUidQuery, useGetDataSourceCacheConfigQuery, + useLazyGetDataSourceCacheConfigQuery, useSetDataSourceCacheConfigMutation, useCleanDataSourceCacheMutation, useDisableDataSourceCacheMutation, useEnableDataSourceCacheMutation, useQueryMetricsWithExpressionsMutation, useGetFoldersQuery, + useLazyGetFoldersQuery, useCreateFolderMutation, useDeleteFolderMutation, useGetFolderByUidQuery, + useLazyGetFolderByUidQuery, useUpdateFolderMutation, useGetFolderDescendantCountsQuery, + useLazyGetFolderDescendantCountsQuery, useMoveFolderMutation, useGetFolderPermissionListQuery, + useLazyGetFolderPermissionListQuery, useUpdateFolderPermissionsMutation, useGetMappedGroupsQuery, + useLazyGetMappedGroupsQuery, useDeleteGroupMappingsMutation, useCreateGroupMappingsMutation, useUpdateGroupMappingsMutation, useGetGroupRolesQuery, + useLazyGetGroupRolesQuery, useGetHealthQuery, + useLazyGetHealthQuery, useGetLibraryElementsQuery, + useLazyGetLibraryElementsQuery, useCreateLibraryElementMutation, useGetLibraryElementByNameQuery, + useLazyGetLibraryElementByNameQuery, useDeleteLibraryElementByUidMutation, useGetLibraryElementByUidQuery, + useLazyGetLibraryElementByUidQuery, useUpdateLibraryElementMutation, useGetLibraryElementConnectionsQuery, + useLazyGetLibraryElementConnectionsQuery, useGetStatusQuery, + useLazyGetStatusQuery, useRefreshLicenseStatsQuery, + useLazyRefreshLicenseStatsQuery, useDeleteLicenseTokenMutation, useGetLicenseTokenQuery, + useLazyGetLicenseTokenQuery, usePostLicenseTokenMutation, usePostRenewLicenseTokenMutation, useGetSamlLogoutQuery, + useLazyGetSamlLogoutQuery, useGetCurrentOrgQuery, + useLazyGetCurrentOrgQuery, useUpdateCurrentOrgMutation, useUpdateCurrentOrgAddressMutation, useGetPendingOrgInvitesQuery, + useLazyGetPendingOrgInvitesQuery, useAddOrgInviteMutation, useRevokeInviteMutation, useGetOrgPreferencesQuery, + useLazyGetOrgPreferencesQuery, usePatchOrgPreferencesMutation, useUpdateOrgPreferencesMutation, useGetCurrentOrgQuotaQuery, + useLazyGetCurrentOrgQuotaQuery, useGetOrgUsersForCurrentOrgQuery, + useLazyGetOrgUsersForCurrentOrgQuery, useAddOrgUserToCurrentOrgMutation, useGetOrgUsersForCurrentOrgLookupQuery, + useLazyGetOrgUsersForCurrentOrgLookupQuery, useRemoveOrgUserForCurrentOrgMutation, useUpdateOrgUserForCurrentOrgMutation, useSearchOrgsQuery, + useLazySearchOrgsQuery, useCreateOrgMutation, useGetOrgByNameQuery, + useLazyGetOrgByNameQuery, useDeleteOrgByIdMutation, useGetOrgByIdQuery, + useLazyGetOrgByIdQuery, useUpdateOrgMutation, useUpdateOrgAddressMutation, useGetOrgQuotaQuery, + useLazyGetOrgQuotaQuery, useUpdateOrgQuotaMutation, useGetOrgUsersQuery, + useLazyGetOrgUsersQuery, useAddOrgUserMutation, useSearchOrgUsersQuery, + useLazySearchOrgUsersQuery, useRemoveOrgUserMutation, useUpdateOrgUserMutation, useSearchPlaylistsQuery, + useLazySearchPlaylistsQuery, useCreatePlaylistMutation, useDeletePlaylistMutation, useGetPlaylistQuery, + useLazyGetPlaylistQuery, useUpdatePlaylistMutation, useGetPlaylistItemsQuery, + useLazyGetPlaylistItemsQuery, useViewPublicDashboardQuery, + useLazyViewPublicDashboardQuery, useGetPublicAnnotationsQuery, + useLazyGetPublicAnnotationsQuery, useQueryPublicDashboardMutation, useSearchQueriesQuery, + useLazySearchQueriesQuery, useCreateQueryMutation, useUnstarQueryMutation, useStarQueryMutation, useDeleteQueryMutation, usePatchQueryCommentMutation, useListRecordingRulesQuery, + useLazyListRecordingRulesQuery, useCreateRecordingRuleMutation, useUpdateRecordingRuleMutation, useTestCreateRecordingRuleMutation, useDeleteRecordingRuleWriteTargetMutation, useGetRecordingRuleWriteTargetQuery, + useLazyGetRecordingRuleWriteTargetQuery, useCreateRecordingRuleWriteTargetMutation, useDeleteRecordingRuleMutation, useGetReportsQuery, + useLazyGetReportsQuery, useCreateReportMutation, useGetReportsByDashboardUidQuery, + useLazyGetReportsByDashboardUidQuery, useSendReportMutation, useGetSettingsImageQuery, + useLazyGetSettingsImageQuery, useRenderReportCsVsQuery, + useLazyRenderReportCsVsQuery, useRenderReportPdFsQuery, + useLazyRenderReportPdFsQuery, useGetReportSettingsQuery, + useLazyGetReportSettingsQuery, useSaveReportSettingsMutation, useSendTestEmailMutation, usePostAcsMutation, useGetMetadataQuery, + useLazyGetMetadataQuery, useGetSloQuery, + useLazyGetSloQuery, usePostSloMutation, useSearchQuery, + useLazySearchQuery, useListSortOptionsQuery, + useLazyListSortOptionsQuery, useCreateServiceAccountMutation, useSearchOrgServiceAccountsWithPagingQuery, + useLazySearchOrgServiceAccountsWithPagingQuery, useDeleteServiceAccountMutation, useRetrieveServiceAccountQuery, + useLazyRetrieveServiceAccountQuery, useUpdateServiceAccountMutation, useListTokensQuery, + useLazyListTokensQuery, useCreateTokenMutation, useDeleteTokenMutation, useRetrieveJwksQuery, + useLazyRetrieveJwksQuery, useGetSharingOptionsQuery, + useLazyGetSharingOptionsQuery, useCreateDashboardSnapshotMutation, useDeleteDashboardSnapshotByDeleteKeyQuery, + useLazyDeleteDashboardSnapshotByDeleteKeyQuery, useDeleteDashboardSnapshotMutation, useGetDashboardSnapshotQuery, + useLazyGetDashboardSnapshotQuery, useCreateTeamMutation, useSearchTeamsQuery, + useLazySearchTeamsQuery, useRemoveTeamGroupApiQueryMutation, useGetTeamGroupsApiQuery, + useLazyGetTeamGroupsApiQuery, useAddTeamGroupApiMutation, useSearchTeamGroupsQuery, + useLazySearchTeamGroupsQuery, useDeleteTeamByIdMutation, useGetTeamByIdQuery, + useLazyGetTeamByIdQuery, useUpdateTeamMutation, useGetTeamMembersQuery, + useLazyGetTeamMembersQuery, useAddTeamMemberMutation, useSetTeamMembershipsMutation, useRemoveTeamMemberMutation, useUpdateTeamMemberMutation, useGetTeamPreferencesQuery, + useLazyGetTeamPreferencesQuery, useUpdateTeamPreferencesMutation, useGetSignedInUserQuery, + useLazyGetSignedInUserQuery, useUpdateSignedInUserMutation, useGetUserAuthTokensQuery, + useLazyGetUserAuthTokensQuery, useUpdateUserEmailQuery, + useLazyUpdateUserEmailQuery, useClearHelpFlagsQuery, + useLazyClearHelpFlagsQuery, useSetHelpFlagMutation, useGetSignedInUserOrgListQuery, + useLazyGetSignedInUserOrgListQuery, useChangeUserPasswordMutation, useGetUserPreferencesQuery, + useLazyGetUserPreferencesQuery, usePatchUserPreferencesMutation, useUpdateUserPreferencesMutation, useGetUserQuotasQuery, + useLazyGetUserQuotasQuery, useRevokeUserAuthTokenMutation, useUnstarDashboardByUidMutation, useStarDashboardByUidMutation, useGetSignedInUserTeamListQuery, + useLazyGetSignedInUserTeamListQuery, useUserSetUsingOrgMutation, useSearchUsersQuery, + useLazySearchUsersQuery, useGetUserByLoginOrEmailQuery, + useLazyGetUserByLoginOrEmailQuery, useSearchUsersWithPagingQuery, + useLazySearchUsersWithPagingQuery, useGetUserByIdQuery, + useLazyGetUserByIdQuery, useUpdateUserMutation, useGetUserOrgListQuery, + useLazyGetUserOrgListQuery, useGetUserTeamsQuery, + useLazyGetUserTeamsQuery, useRouteGetAlertRulesQuery, + useLazyRouteGetAlertRulesQuery, useRoutePostAlertRuleMutation, useRouteGetAlertRulesExportQuery, + useLazyRouteGetAlertRulesExportQuery, useRouteDeleteAlertRuleMutation, useRouteGetAlertRuleQuery, + useLazyRouteGetAlertRuleQuery, useRoutePutAlertRuleMutation, useRouteGetAlertRuleExportQuery, + useLazyRouteGetAlertRuleExportQuery, useRouteGetContactpointsQuery, + useLazyRouteGetContactpointsQuery, useRoutePostContactpointsMutation, useRouteGetContactpointsExportQuery, + useLazyRouteGetContactpointsExportQuery, useRouteDeleteContactpointsMutation, useRoutePutContactpointMutation, useRouteDeleteAlertRuleGroupMutation, useRouteGetAlertRuleGroupQuery, + useLazyRouteGetAlertRuleGroupQuery, useRoutePutAlertRuleGroupMutation, useRouteGetAlertRuleGroupExportQuery, + useLazyRouteGetAlertRuleGroupExportQuery, useRouteGetMuteTimingsQuery, + useLazyRouteGetMuteTimingsQuery, useRoutePostMuteTimingMutation, useRouteExportMuteTimingsQuery, + useLazyRouteExportMuteTimingsQuery, useRouteDeleteMuteTimingMutation, useRouteGetMuteTimingQuery, + useLazyRouteGetMuteTimingQuery, useRoutePutMuteTimingMutation, useRouteExportMuteTimingQuery, + useLazyRouteExportMuteTimingQuery, useRouteResetPolicyTreeMutation, useRouteGetPolicyTreeQuery, + useLazyRouteGetPolicyTreeQuery, useRoutePutPolicyTreeMutation, useRouteGetPolicyTreeExportQuery, + useLazyRouteGetPolicyTreeExportQuery, useRouteGetTemplatesQuery, + useLazyRouteGetTemplatesQuery, useRouteDeleteTemplateMutation, useRouteGetTemplateQuery, + useLazyRouteGetTemplateQuery, useRoutePutTemplateMutation, useListAllProvidersSettingsQuery, + useLazyListAllProvidersSettingsQuery, useRemoveProviderSettingsMutation, useGetProviderSettingsQuery, + useLazyGetProviderSettingsQuery, useUpdateProviderSettingsMutation, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/migrate-to-cloud/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/migrate-to-cloud/endpoints.gen.ts index 87bcb349ecd..d1aff7226ee 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/migrate-to-cloud/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/migrate-to-cloud/endpoints.gen.ts @@ -405,18 +405,26 @@ export type LibraryElementResponseIsAResponseStructForLibraryElementDto = { }; export const { useGetSessionListQuery, + useLazyGetSessionListQuery, useCreateSessionMutation, useDeleteSessionMutation, useGetSessionQuery, + useLazyGetSessionQuery, useCreateSnapshotMutation, useGetSnapshotQuery, + useLazyGetSnapshotQuery, useCancelSnapshotMutation, useUploadSnapshotMutation, useGetShapshotListQuery, + useLazyGetShapshotListQuery, useGetResourceDependenciesQuery, + useLazyGetResourceDependenciesQuery, useGetCloudMigrationTokenQuery, + useLazyGetCloudMigrationTokenQuery, useCreateCloudMigrationTokenMutation, useDeleteCloudMigrationTokenMutation, useGetDashboardByUidQuery, + useLazyGetDashboardByUidQuery, useGetLibraryElementByUidQuery, + useLazyGetLibraryElementByUidQuery, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts index 51bf82c23c6..8e1bdfc1c4c 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/playlist/v0alpha1/endpoints.gen.ts @@ -598,14 +598,18 @@ export type Status = { export type Patch = object; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListPlaylistQuery, + useLazyListPlaylistQuery, useCreatePlaylistMutation, useDeletecollectionPlaylistMutation, useGetPlaylistQuery, + useLazyGetPlaylistQuery, useReplacePlaylistMutation, useDeletePlaylistMutation, useUpdatePlaylistMutation, useGetPlaylistStatusQuery, + useLazyGetPlaylistStatusQuery, useReplacePlaylistStatusMutation, useUpdatePlaylistStatusMutation, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts index 60162b33858..71107ce1072 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts @@ -102,5 +102,9 @@ export type UpdatePrefsCmd = { timezone?: 'utc' | 'browser'; weekStart?: string; }; -export const { useGetUserPreferencesQuery, usePatchUserPreferencesMutation, useUpdateUserPreferencesMutation } = - injectedRtkApi; +export const { + useGetUserPreferencesQuery, + useLazyGetUserPreferencesQuery, + usePatchUserPreferencesMutation, + useUpdateUserPreferencesMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts index 2ea0fd00730..0edda5148ea 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts @@ -778,17 +778,23 @@ export type StarsList = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListPreferencesQuery, + useLazyListPreferencesQuery, useCreatePreferencesMutation, useMergedPreferencesQuery, + useLazyMergedPreferencesQuery, useGetPreferencesQuery, + useLazyGetPreferencesQuery, useReplacePreferencesMutation, useDeletePreferencesMutation, useUpdatePreferencesMutation, useListStarsQuery, + useLazyListStarsQuery, useCreateStarsMutation, useDeletecollectionStarsMutation, useGetStarsQuery, + useLazyGetStarsQuery, useReplaceStarsMutation, useDeleteStarsMutation, useUpdateStarsMutation, diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 7d566227d1a..a3c5d30952a 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -1639,39 +1639,57 @@ export type ResourceStats = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListJobQuery, + useLazyListJobQuery, useCreateJobMutation, useDeletecollectionJobMutation, useGetJobQuery, + useLazyGetJobQuery, useReplaceJobMutation, useDeleteJobMutation, useUpdateJobMutation, useListRepositoryQuery, + useLazyListRepositoryQuery, useCreateRepositoryMutation, useDeletecollectionRepositoryMutation, useGetRepositoryQuery, + useLazyGetRepositoryQuery, useReplaceRepositoryMutation, useDeleteRepositoryMutation, useUpdateRepositoryMutation, useGetRepositoryFilesQuery, + useLazyGetRepositoryFilesQuery, useGetRepositoryFilesWithPathQuery, + useLazyGetRepositoryFilesWithPathQuery, useReplaceRepositoryFilesWithPathMutation, useCreateRepositoryFilesWithPathMutation, useDeleteRepositoryFilesWithPathMutation, useGetRepositoryHistoryQuery, + useLazyGetRepositoryHistoryQuery, useGetRepositoryHistoryWithPathQuery, + useLazyGetRepositoryHistoryWithPathQuery, useGetRepositoryJobsQuery, + useLazyGetRepositoryJobsQuery, useCreateRepositoryJobsMutation, useGetRepositoryJobsWithPathQuery, + useLazyGetRepositoryJobsWithPathQuery, useGetRepositoryRefsQuery, + useLazyGetRepositoryRefsQuery, useGetRepositoryRenderWithPathQuery, + useLazyGetRepositoryRenderWithPathQuery, useGetRepositoryResourcesQuery, + useLazyGetRepositoryResourcesQuery, useGetRepositoryStatusQuery, + useLazyGetRepositoryStatusQuery, useReplaceRepositoryStatusMutation, useUpdateRepositoryStatusMutation, useCreateRepositoryTestMutation, useGetRepositoryWebhookQuery, + useLazyGetRepositoryWebhookQuery, useCreateRepositoryWebhookMutation, useGetFrontendSettingsQuery, + useLazyGetFrontendSettingsQuery, useGetResourceStatsQuery, + useLazyGetResourceStatsQuery, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts index 374c96482a4..a4bfc1df1ba 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/shorturl/v1alpha1/endpoints.gen.ts @@ -599,15 +599,20 @@ export type GetGoto = { }; export const { useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, useListShortUrlQuery, + useLazyListShortUrlQuery, useCreateShortUrlMutation, useDeletecollectionShortUrlMutation, useGetShortUrlQuery, + useLazyGetShortUrlQuery, useReplaceShortUrlMutation, useDeleteShortUrlMutation, useUpdateShortUrlMutation, useGetShortUrlGotoQuery, + useLazyGetShortUrlGotoQuery, useGetShortUrlStatusQuery, + useLazyGetShortUrlStatusQuery, useReplaceShortUrlStatusMutation, useUpdateShortUrlStatusMutation, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index 18fd3cbee59..a9422a02815 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -17,6 +17,12 @@ type OperationDefinition = { }; type EndpointMatcher = string[] | ((operationName: string, operationDefinition: OperationDefinition) => boolean); +const defaultHooksOptions = { + queries: true, + lazyQueries: true, + mutations: true, +}; + /** * Helper to return consistent base API generation config */ @@ -28,7 +34,7 @@ const createAPIConfig = (app: string, version: string, filterEndpoints?: Endpoin apiFile: `../clients/rtkq/${app}/${version}/baseAPI.ts`, filterEndpoints, tag: true, - hooks: true, + hooks: defaultHooksOptions, ...additional, }, }; @@ -43,7 +49,7 @@ const config: ConfigFile = { // OpenAPI3 client with all endpoints '../clients/rtkq/legacy/endpoints.gen.ts': { schemaFile: path.join(basePath, 'public/openapi3.json'), - hooks: true, + hooks: defaultHooksOptions, tag: true, apiFile: '../clients/rtkq/legacy/baseAPI.ts', filterEndpoints: (_name, operation) => !operation.operation.deprecated, @@ -51,7 +57,7 @@ const config: ConfigFile = { '../clients/rtkq/migrate-to-cloud/endpoints.gen.ts': { schemaFile: path.join(basePath, 'public/openapi3.json'), apiFile: '../clients/rtkq/migrate-to-cloud/baseAPI.ts', - hooks: true, + hooks: defaultHooksOptions, filterEndpoints: [ 'getSessionList', 'getSession', @@ -76,13 +82,13 @@ const config: ConfigFile = { }, '../clients/rtkq/preferences/user/endpoints.gen.ts': { schemaFile: path.join(basePath, 'public/openapi3.json'), - hooks: true, + hooks: defaultHooksOptions, apiFile: '../clients/rtkq/preferences/user/baseAPI.ts', filterEndpoints: ['getUserPreferences', 'updateUserPreferences', 'patchUserPreferences'], }, '../clients/rtkq/user/endpoints.gen.ts': { schemaFile: path.join(basePath, 'public/openapi3.json'), - hooks: true, + hooks: defaultHooksOptions, apiFile: '../clients/rtkq/user/baseAPI.ts', filterEndpoints: ['starDashboardByUid', 'unstarDashboardByUid'], }, From c4879522795b77d141386c6faecb91df4e1d11b5 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 30 Oct 2025 10:04:08 -0400 Subject: [PATCH 013/555] Table: Support DataLinks and Actions in SparklineCell (#112244) * Table: Support DataLinks in SparklineCell * add data links to sparkline gdev * fix migrator test * Clean up single action use case --- .../panel-table/table_sparkline_cell.v42.json | 24 +++++++++++++++++-- .../panel-table/table_sparkline_cell.json | 22 ++++++++++++++++- .../Table/TableNG/Cells/SparklineCell.tsx | 23 ++++++++++++------ .../src/components/Table/TableNG/TableNG.tsx | 8 +------ .../TableNG/components/MaybeWrapWithLink.tsx | 1 + .../src/components/Table/TableNG/types.ts | 1 - .../src/components/Table/TableNG/utils.ts | 9 ------- 7 files changed, 61 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-table/table_sparkline_cell.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-table/table_sparkline_cell.v42.json index 3c8310630d7..dae0d524c23 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-table/table_sparkline_cell.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-table/table_sparkline_cell.v42.json @@ -208,7 +208,27 @@ "value": 80 } ] - } + }, + "actions": [ + { + "fetch": { + "body": "{}", + "headers": [["Content-Type", "application/json"]], + "method": "GET", + "queryParams": [], + "url": "/api/health" + }, + "title": "Get instance health", + "type": "fetch" + } + ], + "links": [ + { + "targetBlank": true, + "title": "Google Grafana", + "url": "https://google.com/search?q=grafana" + } + ] }, "overrides": [] }, @@ -592,4 +612,4 @@ "title": "Panel Tests - Table - Sparklines", "uid": "d6373b49-1957-4f00-9218-ee2120d3ecd9", "weekStart": "" -} \ No newline at end of file +} diff --git a/devenv/dev-dashboards/panel-table/table_sparkline_cell.json b/devenv/dev-dashboards/panel-table/table_sparkline_cell.json index 3337c794279..4725580cf0e 100644 --- a/devenv/dev-dashboards/panel-table/table_sparkline_cell.json +++ b/devenv/dev-dashboards/panel-table/table_sparkline_cell.json @@ -204,7 +204,27 @@ "value": 80 } ] - } + }, + "actions": [ + { + "fetch": { + "body": "{}", + "headers": [["Content-Type", "application/json"]], + "method": "GET", + "queryParams": [], + "url": "/api/health" + }, + "title": "Get instance health", + "type": "fetch" + } + ], + "links": [ + { + "targetBlank": true, + "title": "Google Grafana", + "url": "https://google.com/search?q=grafana" + } + ] }, "overrides": [] }, diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx index acedb2091a7..453fdb237b0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx @@ -17,6 +17,7 @@ import { import { measureText } from '../../../../utils/measureText'; import { FormattedValueDisplay } from '../../../FormattedValueDisplay/FormattedValueDisplay'; import { Sparkline } from '../../../Sparkline/Sparkline'; +import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink'; import { SparklineCellProps, TableCellStyles } from '../types'; import { getAlignmentFactor, getCellOptions, prepareSparklineValue } from '../utils'; @@ -38,7 +39,11 @@ export const SparklineCell = (props: SparklineCellProps) => { const sparkline = prepareSparklineValue(value, field); if (!sparkline) { - return <>{field.config.noValue || t('grafana-ui.table.sparkline.no-data', 'no data')}; + return ( + + {field.config.noValue || t('grafana-ui.table.sparkline.no-data', 'no data')} + + ); } // Get the step from the first two values to null-fill the x-axis based on timerange @@ -87,10 +92,10 @@ export const SparklineCell = (props: SparklineCellProps) => { } return ( - <> + {valueElement} - + ); }; @@ -107,8 +112,12 @@ function getTableSparklineCellOptions(field: Field): TableSparklineCellOptions { export const getStyles: TableCellStyles = (theme, { textAlign }) => css({ - width: '100%', - gap: theme.spacing(1), - justifyContent: 'space-between', - ...(textAlign === 'right' && { flexDirection: 'row-reverse' }), + '&, & > a': { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1), + ...(textAlign === 'right' && { flexDirection: 'row-reverse' }), + }, }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 5f64c58773a..a3772785d63 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -94,7 +94,6 @@ import { predicateByName, shouldTextOverflow, shouldTextWrap, - withDataLinksActionsTooltip, getSummaryCellTextAlign, parseStyleJson, IS_SAFARI_26, @@ -406,7 +405,6 @@ export function TableNG(props: TableNGProps) { const result: FromFieldsResult = { columns: [], cellRootRenderers: {}, - colsWithTooltip: {}, }; let lastRowIdx = -1; @@ -464,7 +462,6 @@ export function TableNG(props: TableNGProps) { const shouldOverflow = !IS_SAFARI_26 && rowHeight !== 'auto' && (shouldTextOverflow(field) || Boolean(maxRowHeight)); const textWrap = rowHeight === 'auto' || shouldTextWrap(field); - const withTooltip = withDataLinksActionsTooltip(field, cellType); const canBeColorized = canFieldBeColorized(cellType, applyToRowBgFn); const cellStyleOptions: TableCellStyleOptions = { textAlign, @@ -473,8 +470,6 @@ export function TableNG(props: TableNGProps) { maxHeight: maxRowHeight, }; - result.colsWithTooltip[displayName] = withTooltip; - const defaultCellStyles = getDefaultCellStyles(theme, cellStyleOptions); const cellSpecificStyles = getCellSpecificStyles(cellType, field, theme, cellStyleOptions); const linkStyles = getLinkStyles(theme, canBeColorized); @@ -737,7 +732,7 @@ export function TableNG(props: TableNGProps) { ); const [nestedFieldWidths] = useColWidths(firstRowNestedData?.fields ?? [], availableWidth); - const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => { + const { columns, cellRootRenderers } = useMemo(() => { const result = fromFields(visibleFields, widths); // if nested frames are present, augment the columns to include the nested table expander column. @@ -805,7 +800,6 @@ export function TableNG(props: TableNGProps) { const field = columns[column.idx].field; if ( - colsWithTooltip[getDisplayName(field)] && target instanceof HTMLElement && // this walks up the tree to find either a faux link wrapper or the cell root // it then only proceeds if we matched the faux link wrapper diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx index b43728dd1e8..7758bf8fd20 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx @@ -34,4 +34,5 @@ export const MaybeWrapWithLink = memo(({ field, rowIdx, children }: MaybeWrapWit // raw value return children; }); + MaybeWrapWithLink.displayName = 'MaybeWrapWithLink'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index c4d63b2826b..b828dfddbb6 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -308,7 +308,6 @@ export type CellRootRenderer = (key: React.Key, props: CellRendererProps; - colsWithTooltip: Record; } export interface FooterFieldState extends FieldState { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index e3ab23b3e0e..867ee96b983 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -955,15 +955,6 @@ export function getApplyToRowBgFn( } } -/** @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 - ); -} - /** @internal */ export function canFieldBeColorized( cellType: TableCellDisplayMode, From c3d7dbc2585f84a65dba3b8c54490e10fff63102 Mon Sep 17 00:00:00 2001 From: Kyle Brandt Date: Thu, 30 Oct 2025 10:05:12 -0400 Subject: [PATCH 014/555] SQL Expressions: Add endpoint to get Schemas (#108864) Return the SQL schema for all DS queries in request (to provide information to AI / Autocomplete for SQL expressions). All DS queries are treated as if they were inputs to SQL expressions in terms of conversion, regardless if they are selected in a query or not. Requires feature toggle queryService = true Endpoint is apis/query.grafana.app/v0alpha1/namespaces/default/sqlschemas --------- Co-authored-by: Todd Treece <360020+toddtreece@users.noreply.github.com> --- pkg/apis/query/v0alpha1/query.go | 9 + .../query/v0alpha1/zz_generated.deepcopy.go | 26 ++ .../query/v0alpha1/zz_generated.openapi.go | 27 ++ pkg/expr/sql/frame_table.go | 4 +- pkg/expr/sql_schema.go | 231 ++++++++++++++++++ pkg/registry/apis/query/query.go | 79 ++++-- pkg/registry/apis/query/register.go | 3 + pkg/registry/apis/query/sql_schema.go | 170 +++++++++++++ pkg/services/apiserver/builder/helper.go | 6 + .../public_dashboard_service_mock.go | 7 + .../publicdashboards/publicdashboard.go | 4 + .../publicdashboards/service/service.go | 5 + pkg/services/query/expr_sql_schema.go | 75 ++++++ pkg/services/query/query.go | 2 + pkg/services/query/query_service_mock.go | 6 + 15 files changed, 630 insertions(+), 24 deletions(-) create mode 100644 pkg/expr/sql_schema.go create mode 100644 pkg/registry/apis/query/sql_schema.go create mode 100644 pkg/services/query/expr_sql_schema.go diff --git a/pkg/apis/query/v0alpha1/query.go b/pkg/apis/query/v0alpha1/query.go index ef40c17ff45..f6ad172c348 100644 --- a/pkg/apis/query/v0alpha1/query.go +++ b/pkg/apis/query/v0alpha1/query.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/grafana/grafana/pkg/expr" ) // Generic query request with shared time across all values @@ -28,6 +29,14 @@ type QueryDataResponse struct { backend.QueryDataResponse `json:",inline"` } +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type SQLSchemas struct { + metav1.TypeMeta `json:",inline"` + + // Backend wrapper (external dependency) + expr.SQLSchemas `json:"sqlSchemas,inline"` +} + // GetResponseCode return the right status code for the response by checking the responses. func GetResponseCode(rsp *backend.QueryDataResponse) int { if rsp == nil { diff --git a/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go index 38a7bd0b115..ad159a4a6cb 100644 --- a/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/query/v0alpha1/zz_generated.deepcopy.go @@ -262,3 +262,29 @@ func (in *QueryTypeDefinitionList) DeepCopyObject() runtime.Object { } return nil } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SQLSchemas) DeepCopyInto(out *SQLSchemas) { + *out = *in + out.TypeMeta = in.TypeMeta + out.SQLSchemas = in.SQLSchemas.DeepCopy() + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SQLSchemas. +func (in *SQLSchemas) DeepCopy() *SQLSchemas { + if in == nil { + return nil + } + out := new(SQLSchemas) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *SQLSchemas) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/apis/query/v0alpha1/zz_generated.openapi.go b/pkg/apis/query/v0alpha1/zz_generated.openapi.go index 2ee9ed95395..68f42312ce9 100644 --- a/pkg/apis/query/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/query/v0alpha1/zz_generated.openapi.go @@ -23,6 +23,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref), "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition": schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref), "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinitionList": schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref), + "github.com/grafana/grafana/pkg/apis/query/v0alpha1.SQLSchemas": schema_pkg_apis_query_v0alpha1_SQLSchemas(ref), } } @@ -482,3 +483,29 @@ func schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref common.Reference "github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, } } + +func schema_pkg_apis_query_v0alpha1_SQLSchemas(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/expr/sql/frame_table.go b/pkg/expr/sql/frame_table.go index 2ac897b5007..245efc6e655 100644 --- a/pkg/expr/sql/frame_table.go +++ b/pkg/expr/sql/frame_table.go @@ -30,7 +30,7 @@ func (ft *FrameTable) String() string { return ft.Name() } -func schemaFromFrame(frame *data.Frame) mysql.Schema { +func SchemaFromFrame(frame *data.Frame) mysql.Schema { schema := make(mysql.Schema, len(frame.Fields)) for i, field := range frame.Fields { @@ -48,7 +48,7 @@ func schemaFromFrame(frame *data.Frame) mysql.Schema { // Schema implements the mysql.Table interface func (ft *FrameTable) Schema() mysql.Schema { if ft.schema == nil { - ft.schema = schemaFromFrame(ft.Frame) + ft.schema = SchemaFromFrame(ft.Frame) } return ft.schema } diff --git a/pkg/expr/sql_schema.go b/pkg/expr/sql_schema.go new file mode 100644 index 00000000000..c3851eed8bd --- /dev/null +++ b/pkg/expr/sql_schema.go @@ -0,0 +1,231 @@ +package expr + +import ( + "context" + "reflect" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr/mathexp" + "github.com/grafana/grafana/pkg/expr/sql" +) + +// BasicColumn represents the column type for data that is input to a SQL expression. +type BasicColumn struct { + Name string `json:"name"` + MySQLType string `json:"mysqlType"` + Nullable bool `json:"nullable"` + DataFrameFieldType data.FieldType `json:"dataFrameFieldType"` +} + +// SchemaInfo provides information and some sample data for data that could be an input +// to a SQL expression. +type SchemaInfo struct { + Columns []BasicColumn `json:"columns"` + SampleRows [][]any `json:"sampleRows"` + Error string `json:"error,omitempty"` +} + +// SQLSchemas returns info about what the Schema for a DS query will be like if the +// query were to be used an input to SQL expressions. So effectively post SQL expressions input +// conversion. +// There is a a manual DeepCopy at the end of this file that will need to be updated when this our the +// underlying structs are change. The hack script will also need to be run to update the Query service API +// generated types. +type SQLSchemas map[string]SchemaInfo + +// GetSQLSchemas returns what the schemas are for SQL expressions for all DS queries +// in the request. It executes the queries to get the schemas. +// Intended use is for autocomplete and AI, so used during the authoring/editing experience only. +func (s *Service) GetSQLSchemas(ctx context.Context, req Request) (SQLSchemas, error) { + // Extract DS Nodes and Execute Them + // Building the pipeline is maybe not best, as it can have more errors. + filtered := make([]Query, 0, len(req.Queries)) + for _, q := range req.Queries { + if NodeTypeFromDatasourceUID(q.DataSource.UID) == TypeDatasourceNode { + filtered = append(filtered, q) + } + } + req.Queries = filtered + pipeline, err := s.buildPipeline(ctx, &req) + if err != nil { + return nil, err + } + + var schemas = make(SQLSchemas) + + for _, node := range pipeline { + // For now, execute calls convert at the end, so we are being lazy and running the full conversion. Longer run we want to run without + // full conversion and just get the schema. Maybe conversion should be + dsNode := node.(*DSNode) + // Make all input to SQL + dsNode.isInputToSQLExpr = true + + // TODO: check where time is coming from, don't recall + res, err := dsNode.Execute(ctx, time.Now(), mathexp.Vars{}, s) + if err != nil { + schemas[dsNode.RefID()] = SchemaInfo{Error: err.Error()} + continue + // we want to continue and get the schemas we can + } + if res.Error != nil { + schemas[dsNode.RefID()] = SchemaInfo{Error: res.Error.Error()} + continue + // we want to continue and get the schemas we can + } + + frames := res.Values.AsDataFrames(dsNode.RefID()) + if len(frames) == 0 { + schemas[dsNode.RefID()] = SchemaInfo{Error: "no data"} + } + frame := frames[0] + + schema := sql.SchemaFromFrame(frame) + columns := make([]BasicColumn, 0, len(schema)) + for _, col := range schema { + fT, _ := sql.MySQLColToFieldType(col) + columns = append(columns, BasicColumn{ + Name: col.Name, + MySQLType: col.Type.String(), + Nullable: col.Nullable, + DataFrameFieldType: fT, + }) + } + + // Cap at 3 rows. + const maxRows = 3 + n := frame.Rows() + if n > maxRows { + n = maxRows + } + sampleRows := make([][]any, 0, n) + for i := 0; i < n; i++ { + sampleRows = append(sampleRows, frame.RowCopy(i)) + } + + schemas[dsNode.RefID()] = SchemaInfo{Columns: columns, SampleRows: sampleRows} + } + + return schemas, nil +} + +// DeepCopy returns a deep copy of the schema. +// Used AI to make it, the kubernetes one doesn't like any or interface{} +func (s SQLSchemas) DeepCopy() SQLSchemas { + if s == nil { + return nil + } + out := make(SQLSchemas, len(s)) + for k, v := range s { + out[k] = SchemaInfo{ + Columns: copyColumns(v.Columns), + SampleRows: deepCopySampleRows2D(v.SampleRows), + Error: v.Error, + } + } + return out +} + +func copyColumns(in []BasicColumn) []BasicColumn { + if in == nil { + return nil + } + out := make([]BasicColumn, len(in)) + copy(out, in) // BasicColumn is value-only, so this suffices + return out +} + +// Deep-copy [][]any preserving nil vs empty slices and cloning elements. +func deepCopySampleRows2D(in [][]any) [][]any { + if in == nil { + return nil + } + out := make([][]any, len(in)) + for i, row := range in { + if row == nil { + // preserve nil inner slice + continue + } + newRow := make([]any, len(row)) + for j, v := range row { + newRow[j] = deepCopyAny(v) + } + out[i] = newRow + } + return out +} + +// Recursively clone pointers, maps, slices, arrays, and interfaces. +// Structs are copied by value (shallow for their internals). +func deepCopyAny(v any) any { + if v == nil { + return nil + } + return deepCopyRV(reflect.ValueOf(v)).Interface() +} + +func deepCopyRV(rv reflect.Value) reflect.Value { + if !rv.IsValid() { + return rv + } + + switch rv.Kind() { + case reflect.Ptr: + if rv.IsNil() { + return rv + } + elemCopy := deepCopyRV(rv.Elem()) + newPtr := reflect.New(rv.Type().Elem()) + if elemCopy.Type().AssignableTo(newPtr.Elem().Type()) { + newPtr.Elem().Set(elemCopy) + } else if elemCopy.Type().ConvertibleTo(newPtr.Elem().Type()) { + newPtr.Elem().Set(elemCopy.Convert(newPtr.Elem().Type())) + } else { + newPtr.Elem().Set(rv.Elem()) // fallback: shallow + } + return newPtr + + case reflect.Interface: + if rv.IsNil() { + return rv + } + return deepCopyRV(rv.Elem()) + + case reflect.Map: + if rv.IsNil() { + return reflect.Zero(rv.Type()) + } + newMap := reflect.MakeMapWithSize(rv.Type(), rv.Len()) + for _, k := range rv.MapKeys() { + newMap.SetMapIndex(deepCopyRV(k), deepCopyRV(rv.MapIndex(k))) + } + return newMap + + case reflect.Slice: + if rv.IsNil() { + return reflect.Zero(rv.Type()) + } + n := rv.Len() + newSlice := reflect.MakeSlice(rv.Type(), n, n) + for i := 0; i < n; i++ { + newSlice.Index(i).Set(deepCopyRV(rv.Index(i))) + } + return newSlice + + case reflect.Array: + n := rv.Len() + newArr := reflect.New(rv.Type()).Elem() + for i := 0; i < n; i++ { + newArr.Index(i).Set(deepCopyRV(rv.Index(i))) + } + return newArr + + case reflect.Struct: + // Value copy (OK unless the struct contains references you also want deep-copied). + return rv + + default: + // Scalars (string, bool, numbers), etc. + return rv + } +} diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index f63cb850bc7..30a1ae6b567 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -241,25 +241,40 @@ func (r *queryREST) Connect(connectCtx context.Context, name string, _ runtime.O }), nil } -func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuilder, httpreq *http.Request, responder responderWrapper, connectLogger log.Logger) (*backend.QueryDataResponse, error) { - var jsonQueries = make([]*simplejson.Json, 0, len(raw.Queries)) - for _, query := range raw.Queries { - dsRef, err := getValidDataSourceRef(ctx, query.Datasource, query.DatasourceID, b.legacyDatasourceLookup) - if err != nil { - connectLogger.Error("error getting valid datasource ref", err) - } - if dsRef != nil { - query.Datasource = dsRef +type preparedQuery struct { + mReq dtos.MetricRequest + cache datasources.CacheService + headers map[string]string + logger log.Logger + builder dsquerierclient.QSDatasourceClientBuilder + exprSvc *expr.Service + reportMetrics func() +} + +func prepareQuery( + ctx context.Context, + raw query.QueryDataRequest, + b QueryAPIBuilder, + httpreq *http.Request, + connectLogger log.Logger, +) (*preparedQuery, error) { + // Normalize DS refs and build []*simplejson.Json + jsonQueries := make([]*simplejson.Json, 0, len(raw.Queries)) + for _, q := range raw.Queries { + if dsRef, derr := getValidDataSourceRef(ctx, q.Datasource, q.DatasourceID, b.legacyDatasourceLookup); derr != nil { + connectLogger.Error("error getting valid datasource ref", "err", derr) + } else if dsRef != nil { + q.Datasource = dsRef } - jsonBytes, err := json.Marshal(query) + jsonBytes, err := json.Marshal(q) if err != nil { - connectLogger.Error("error marshalling", err) + connectLogger.Error("error marshalling query", "err", err) } - sjQuery, _ := simplejson.NewJson(jsonBytes) + sjQuery, err := simplejson.NewJson(jsonBytes) if err != nil { - connectLogger.Error("error unmarshalling", err) + connectLogger.Error("error creating simplejson for query", "err", err) } jsonQueries = append(jsonQueries, sjQuery) @@ -274,13 +289,11 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil cache := &MyCacheService{ legacy: b.legacyDatasourceLookup, } - headers := ExtractKnownHeaders(httpreq.Header) instance, err := b.instanceProvider.GetInstance(ctx, connectLogger, headers) if err != nil { connectLogger.Error("failed to get instance configuration settings", "err", err) - responder.Error(err) return nil, err } @@ -288,12 +301,14 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil dsQuerierLoggerWithSlug := instance.GetLogger() + // Datasource client qsDsClientBuilder qsDsClientBuilder := dsquerierclient.NewQsDatasourceClientBuilderWithInstance( instance, ctx, dsQuerierLoggerWithSlug, ) + // Expressions service exprService := expr.ProvideService( &setting.Cfg{ ExpressionsEnabled: instanceConfig.ExpressionsEnabled, @@ -310,17 +325,37 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil qsDsClientBuilder, ) - qdr, err := service.QueryData(ctx, dsQuerierLoggerWithSlug, cache, exprService, mReq, qsDsClientBuilder, headers) + return &preparedQuery{ + mReq: mReq, + cache: cache, + headers: headers, + logger: dsQuerierLoggerWithSlug, + builder: qsDsClientBuilder, + exprSvc: exprService, + reportMetrics: func() { instance.ReportMetrics() }, + }, nil +} - // tell the `instance` structure that it can now report - // metrics that are only reported once during a request - instance.ReportMetrics() +func handlePreparedQuery(ctx context.Context, pq *preparedQuery) (*backend.QueryDataResponse, error) { + resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers) + pq.reportMetrics() + return resp, err +} +func handleQuery( + ctx context.Context, + raw query.QueryDataRequest, + b QueryAPIBuilder, + httpreq *http.Request, + responder responderWrapper, + connectLogger log.Logger, +) (*backend.QueryDataResponse, error) { + pq, err := prepareQuery(ctx, raw, b, httpreq, connectLogger) if err != nil { - return qdr, err + responder.Error(err) + return nil, err } - - return qdr, nil + return handlePreparedQuery(ctx, pq) } type responderWrapper struct { diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index 109de13276c..221b589b511 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -161,6 +161,7 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { &query.QueryDataResponse{}, &query.QueryTypeDefinition{}, &query.QueryTypeDefinitionList{}, + &query.SQLSchemas{}, ) } @@ -201,6 +202,8 @@ func (b *QueryAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIG // The query endpoint -- NOTE, this uses a rewrite hack to allow requests without a name parameter storage["query"] = newQueryREST(b) + storage["sqlschemas"] = newSQLSchemasREST(b) + // Register the expressions query schemas err := queryschema.RegisterQueryTypes(b.queryTypes, storage) diff --git a/pkg/registry/apis/query/sql_schema.go b/pkg/registry/apis/query/sql_schema.go new file mode 100644 index 00000000000..10ec3dc2526 --- /dev/null +++ b/pkg/registry/apis/query/sql_schema.go @@ -0,0 +1,170 @@ +package query + +import ( + "context" + "net/http" + "strconv" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/expr" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + errorsK8s "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + service "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/web" +) + +type sqlSchemaREST struct { + logger log.Logger + builder *QueryAPIBuilder +} + +var ( + _ rest.Storage = (*sqlSchemaREST)(nil) + _ rest.SingularNameProvider = (*sqlSchemaREST)(nil) + _ rest.Connecter = (*sqlSchemaREST)(nil) + _ rest.Scoper = (*sqlSchemaREST)(nil) + _ rest.StorageMetadata = (*sqlSchemaREST)(nil) +) + +func newSQLSchemasREST(builder *QueryAPIBuilder) *sqlSchemaREST { + return &sqlSchemaREST{ + logger: log.New("query.sqlschemas"), + builder: builder, + } +} + +func (r *sqlSchemaREST) New() runtime.Object { + // This is added as the "ResponseType" regardless what ProducesObject() says :) + return &query.SQLSchemas{} +} + +func (r *sqlSchemaREST) Destroy() {} + +func (r *sqlSchemaREST) NamespaceScoped() bool { + return true +} + +func (r *sqlSchemaREST) GetSingularName() string { + return "SQLSchema" // Used for the +} + +func (r *sqlSchemaREST) ProducesMIMETypes(verb string) []string { + return []string{"application/json"} // and parquet! +} + +func (r *sqlSchemaREST) ProducesObject(verb string) interface{} { + return &query.SQLSchemas{} +} + +func (r *sqlSchemaREST) ConnectMethods() []string { + return []string{"POST"} +} + +func (r *sqlSchemaREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +// called by mt query service and also when queryServiceFromUI is enabled, can be both mt and st +func (r *sqlSchemaREST) Connect(connectCtx context.Context, name string, _ runtime.Object, incomingResponder rest.Responder) (http.Handler, error) { + // See: /pkg/services/apiserver/builder/helper.go#L34 + // The name is set with a rewriter hack + if name != "name" { + r.logger.Debug("Connect name is not name") + return nil, errorsK8s.NewNotFound(schema.GroupResource{}, name) + } + b := r.builder + + return http.HandlerFunc(func(w http.ResponseWriter, httpreq *http.Request) { + ctx, span := b.tracer.Start(httpreq.Context(), "QueryService.GetSQLSchemas") + defer span.End() + ctx = request.WithNamespace(ctx, request.NamespaceValue(connectCtx)) + traceId := span.SpanContext().TraceID() + connectLogger := b.log.New("traceId", traceId.String(), "rule_uid", httpreq.Header.Get("X-Rule-Uid")) + responder := newResponderWrapper(incomingResponder, + func(statusCode *int, obj runtime.Object) { + if *statusCode/100 == 4 { + span.SetStatus(codes.Error, strconv.Itoa(*statusCode)) + } + + if *statusCode >= 500 { + o, ok := obj.(*query.QueryDataResponse) + if ok && o.Responses != nil { + for refId, response := range o.Responses { + if response.ErrorSource == backend.ErrorSourceDownstream { + *statusCode = http.StatusBadRequest //force this to be a 400 since it's downstream + span.SetStatus(codes.Error, strconv.Itoa(*statusCode)) + span.SetAttributes(attribute.String("error.source", "downstream")) + break + } else if response.Error != nil { + connectLogger.Debug("500 error without downstream error source", "error", response.Error, "errorSource", response.ErrorSource, "refId", refId) + span.SetStatus(codes.Error, "500 error without downstream error source") + } else { + span.SetStatus(codes.Error, "500 error without downstream error source and no Error message") + span.SetAttributes(attribute.String("error.ref_id", refId)) + } + } + } + } + }, + + func(err error) { + connectLogger.Error("error caught in handler", "err", err) + span.SetStatus(codes.Error, "query error") + + if err == nil { + return + } + + span.RecordError(err) + }) + + raw := &query.QueryDataRequest{} + err := web.Bind(httpreq, raw) + if err != nil { + connectLogger.Error("Hit unexpected error when reading query", "err", err) + err = errorsK8s.NewBadRequest("error reading query") + responder.Error(err) + return + } + + qdr, err := handleSQLSchemaQuery(ctx, *raw, *b, httpreq, *responder, connectLogger) + if err != nil { + responder.Error(err) + return + } + + responder.Object(200, &query.SQLSchemas{ + SQLSchemas: qdr, + }) + }), nil +} + +func handlePreparedSQLSchema(ctx context.Context, pq *preparedQuery) (expr.SQLSchemas, error) { + resp, err := service.GetSQLSchemas(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers) + pq.reportMetrics() + return resp, err +} + +func handleSQLSchemaQuery( + ctx context.Context, + raw query.QueryDataRequest, + b QueryAPIBuilder, + httpreq *http.Request, + responder responderWrapper, + connectLogger log.Logger, +) (expr.SQLSchemas, error) { + pq, err := prepareQuery(ctx, raw, b, httpreq, connectLogger) + if err != nil { + responder.Error(err) + return nil, err + } + return handlePreparedSQLSchema(ctx, pq) +} diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index 222637401ee..cfcf3540b43 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -58,6 +58,12 @@ var PathRewriters = []filters.PathRewriter{ return matches[1] + "/name" // connector requires a name }, }, + { + Pattern: regexp.MustCompile(`(/apis/query.grafana.app/v0alpha1/namespaces/.*/sqlschemas$)`), + ReplaceFunc: func(matches []string) string { + return matches[1] + "/name" // connector requires a name + }, + }, { Pattern: regexp.MustCompile(`(/apis/.*/v0alpha1/namespaces/.*/queryconvert$)`), ReplaceFunc: func(matches []string) string { diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index b54ef3ec6db..c2b39355737 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -4,9 +4,12 @@ package publicdashboards import ( context "context" + "fmt" backend "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/expr" dashboards "github.com/grafana/grafana/pkg/services/dashboards" dtos "github.com/grafana/grafana/pkg/api/dtos" @@ -587,6 +590,10 @@ func (_m *FakePublicDashboardService) Update(ctx context.Context, u *user.Signed return r0, r1 } +func (_m *FakePublicDashboardService) GetSQLSchemas(ctx context.Context, user identity.Requester, reqDTO dtos.MetricRequest) (expr.SQLSchemas, error) { + return nil, fmt.Errorf("not implemented in public dashboards") +} + // NewFakePublicDashboardService creates a new instance of FakePublicDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewFakePublicDashboardService(t interface { diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index c3c2eb83496..b31657ef267 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -5,6 +5,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/expr" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" @@ -37,6 +39,8 @@ type Service interface { ExistsEnabledByAccessToken(ctx context.Context, accessToken string) (bool, error) ExistsEnabledByDashboardUid(ctx context.Context, dashboardUid string) (bool, error) + + GetSQLSchemas(ctx context.Context, user identity.Requester, reqDTO dtos.MetricRequest) (expr.SQLSchemas, error) } // ServiceWrapper these methods have different behavior between OSS and Enterprise. The latter would call the OSS service first diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 239ce31829c..bb3c70dcef4 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -512,6 +513,10 @@ func (pd *PublicDashboardServiceImpl) logIsEnabledChanged(existingPubdash *Publi } } +func (pd *PublicDashboardServiceImpl) GetSQLSchemas(ctx context.Context, user identity.Requester, reqDTO dtos.MetricRequest) (expr.SQLSchemas, error) { + return nil, fmt.Errorf("sql schema endpoint not supported with public dashboards") +} + // Checks to see if PublicDashboard.ExistsEnabledByDashboardUid is true on create or changed on update func publicDashboardIsEnabledChanged(existingPubdash *PublicDashboard, newPubdash *PublicDashboard) bool { // creating dashboard, enabled true diff --git a/pkg/services/query/expr_sql_schema.go b/pkg/services/query/expr_sql_schema.go new file mode 100644 index 00000000000..db29da98625 --- /dev/null +++ b/pkg/services/query/expr_sql_schema.go @@ -0,0 +1,75 @@ +package query + +import ( + "context" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/errutil" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/dsquerierclient" + "github.com/grafana/grafana/pkg/services/validations" +) + +func (s *ServiceImpl) GetSQLSchemas(ctx context.Context, user identity.Requester, reqDTO dtos.MetricRequest) (expr.SQLSchemas, error) { + //TODO DEdupe code + + parsedReq, err := s.parseMetricRequest(ctx, user, false, reqDTO, false) + if err != nil { + return expr.SQLSchemas{}, err + } + exprReq := expr.Request{ + Queries: []expr.Query{}, + } + + if user != nil { // for passthrough authentication, SSE does not authenticate + exprReq.User = user + exprReq.OrgId = user.GetOrgID() + } + + for _, pq := range parsedReq.getFlattenedQueries() { + if pq.datasource == nil { + return nil, ErrMissingDataSourceInfo.Build(errutil.TemplateData{ + Public: map[string]any{ + "RefId": pq.query.RefID, + }, + }) + } + + exprReq.Queries = append(exprReq.Queries, expr.Query{ + JSON: pq.query.JSON, + Interval: pq.query.Interval, + RefID: pq.query.RefID, + MaxDataPoints: pq.query.MaxDataPoints, + QueryType: pq.query.QueryType, + DataSource: pq.datasource, + TimeRange: expr.AbsoluteTimeRange{ + From: pq.query.TimeRange.From, + To: pq.query.TimeRange.To, + }, + }) + } + + return s.expressionService.GetSQLSchemas(ctx, exprReq) +} + +func GetSQLSchemas(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string) (expr.SQLSchemas, error) { + s := &ServiceImpl{ + log: log, + dataSourceCache: dscache, + expressionService: exprService, + dataSourceRequestValidator: validations.ProvideValidator(), + qsDatasourceClientBuilder: qsDatasourceClientBuilder, + headers: headers, + concurrentQueryLimit: 16, // TODO: make it configurable + } + + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + return s.GetSQLSchemas(ctx, user, reqDTO) +} diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index d69c69af134..138812ebd03 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -73,6 +73,8 @@ type Service interface { // this is more "forward compatible", for example supports per-query time ranges QueryDataNew(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) + + GetSQLSchemas(ctx context.Context, user identity.Requester, reqDTO dtos.MetricRequest) (expr.SQLSchemas, error) } // Gives us compile time error if the service does not adhere to the contract of the interface diff --git a/pkg/services/query/query_service_mock.go b/pkg/services/query/query_service_mock.go index 22c08118941..c391b86061f 100644 --- a/pkg/services/query/query_service_mock.go +++ b/pkg/services/query/query_service_mock.go @@ -4,10 +4,12 @@ package query import ( context "context" + "fmt" backend "github.com/grafana/grafana-plugin-sdk-go/backend" dtos "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/expr" identity "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -97,6 +99,10 @@ func (_m *FakeQueryService) Run(ctx context.Context) error { return r0 } +func (_m *FakeQueryService) GetSQLSchemas(ctx context.Context, user identity.Requester, reqDTO dtos.MetricRequest) (expr.SQLSchemas, error) { + return nil, fmt.Errorf("sql schema endpoint not supported with public dashboards") +} + // NewFakeQueryService creates a new instance of FakeQueryService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewFakeQueryService(t interface { From 92375279f70485dd56e3fb75e25d43a8937b11cc Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 30 Oct 2025 14:22:44 +0000 Subject: [PATCH 015/555] Slider: Expose prop to control visibility of input (#113084) expose prop to control visibility of slider input --- .../src/components/Slider/Slider.test.tsx | 12 +++++++++ .../src/components/Slider/Slider.tsx | 25 +++++++++++-------- .../grafana-ui/src/components/Slider/types.ts | 2 ++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/grafana-ui/src/components/Slider/Slider.test.tsx b/packages/grafana-ui/src/components/Slider/Slider.test.tsx index e24ccd7e751..1fb4ab1c03a 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.test.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.test.tsx @@ -33,6 +33,18 @@ describe('Slider', () => { expect(sliderInput).toHaveValue('10'); }); + it('hides the slider input if showInput is false', () => { + render(); + + const slider = screen.getByRole('slider'); + const sliderInput = screen.queryByRole('textbox'); + + expect(slider).toHaveAttribute('aria-valuemin', '10'); + expect(slider).toHaveAttribute('aria-valuemax', '20'); + expect(slider).toHaveAttribute('aria-valuenow', '10'); + expect(sliderInput).not.toBeInTheDocument(); + }); + it('renders correct contents with a value', () => { render(); diff --git a/packages/grafana-ui/src/components/Slider/Slider.tsx b/packages/grafana-ui/src/components/Slider/Slider.tsx index b41f41d0af7..d8d310fa92b 100644 --- a/packages/grafana-ui/src/components/Slider/Slider.tsx +++ b/packages/grafana-ui/src/components/Slider/Slider.tsx @@ -29,6 +29,7 @@ export const Slider = ({ marks, included, inputId, + showInput = true, }: SliderProps) => { const isHorizontal = orientation === 'horizontal'; const styles = useStyles2(getStyles, isHorizontal, Boolean(marks)); @@ -114,17 +115,19 @@ export const Slider = ({ included={included} /> - + {showInput && ( + + )}
); diff --git a/packages/grafana-ui/src/components/Slider/types.ts b/packages/grafana-ui/src/components/Slider/types.ts index 812a8bed7d7..880bfd2f712 100644 --- a/packages/grafana-ui/src/components/Slider/types.ts +++ b/packages/grafana-ui/src/components/Slider/types.ts @@ -14,6 +14,8 @@ interface CommonSliderProps { marks?: SliderMarks; /** If the value is true, it means a continuous value interval, otherwise, it is a independent value. */ included?: boolean; + /** Controls visibility of the input field. Defaults to true. */ + showInput?: boolean; } export interface SliderProps extends CommonSliderProps { value?: number; From 63c5d8cb8f1c5432c7ed35569785f669d5b3bda8 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 30 Oct 2025 14:23:03 +0000 Subject: [PATCH 016/555] Collapse: Improve layout and deprecate `collapsible` prop (#113164) * deprecate collapsible prop, improve Collapse to allow for buttons in the header * add ariaLabel * Revert "add ariaLabel" This reverts commit a903a0da5da70065db31f4a98fb6caf0f6f93727. * add aria-labelledby --- .../src/querybuilder/QueryPatternsModal.tsx | 1 - .../querybuilder/shared/QueryOptionGroup.tsx | 1 - .../components/Collapse/Collapse.story.tsx | 39 ++++++++++- .../src/components/Collapse/Collapse.test.tsx | 2 +- .../src/components/Collapse/Collapse.tsx | 65 +++++++++---------- .../src/components/IconButton/IconButton.tsx | 7 +- .../import-to-gma/ConfirmConvertModal.tsx | 1 - .../import-to-gma/ImportToGMARules.tsx | 1 - .../EditDefaultPolicyForm.tsx | 1 - .../features/explore/CorrelationHelper.tsx | 2 - .../features/explore/Logs/LogsContainer.tsx | 6 +- .../features/explore/Logs/LogsSamplePanel.tsx | 1 - .../SpanFilters/SpanFilters.tsx | 2 +- .../logs/components/panel/LogLineContext.tsx | 1 - .../panel/LogLineDetailsComponent.tsx | 7 -- .../data-hover/DataHoverRows.tsx | 1 - .../LogsQueryEditor/AzureCheatSheet.tsx | 1 - .../ResourcePicker/AdvancedMulti.tsx | 1 - .../components/CheatSheet/LogsCheatSheet.tsx | 2 +- .../configuration/ConfigurationEditor.tsx | 2 +- .../QueryEditor/QueryOptionGroup.tsx | 1 - .../loki/components/LokiContextUi.tsx | 1 - .../components/QueryPatternsModal.tsx | 1 - .../configuration/ConfigurationEditor.tsx | 2 +- .../prometheus/QueryOptionGroup.tsx | 1 - 25 files changed, 83 insertions(+), 67 deletions(-) diff --git a/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx b/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx index a4f17087b21..2364bc80a86 100644 --- a/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/QueryPatternsModal.tsx @@ -111,7 +111,6 @@ export const QueryPatternsModal = (props: Props) => { } )} isOpen={isOpen} - collapsible={true} onToggle={() => { const action = isOpen ? 'close' : 'open'; reportInteraction(`grafana_prom_kickstart_toggle_pattern_card`, { diff --git a/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx b/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx index 554201a820d..8253d195ea0 100644 --- a/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx +++ b/packages/grafana-prometheus/src/querybuilder/shared/QueryOptionGroup.tsx @@ -20,7 +20,6 @@ export function QueryOptionGroup({ title, children, collapsedInfo }: Props) {
= { children: 'Panel data', isOpen: false, label: 'Collapse panel', - collapsible: true, }, argTypes: { onToggle: { action: 'toggled' }, @@ -57,4 +59,39 @@ Controlled.parameters = { }, }; +export const WithCustomLabel: StoryFn = (args) => { + const [, updateArgs] = useArgs(); + return ( + { + action('onToggle')({ isOpen: !args.isOpen }); + updateArgs({ isOpen: !args.isOpen }); + }} + label={ + + Collapse panel + + { + event.stopPropagation(); + action('onDeleteClick')(); + }} + aria-label="Delete" + name="trash-alt" + /> + + + } + > +

{args.children}

+
+ ); +}; +WithCustomLabel.parameters = { + controls: { + exclude: [...EXCLUDED_PROPS, 'label'], + }, +}; + export default meta; diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx index 432ff8ef56e..817df10153d 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.test.tsx @@ -42,7 +42,7 @@ describe('Collapse', () => { const onToggle = jest.fn(); const { user } = setup( - +
{contentText}
); diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index d0f8757e1ca..195ea24b4d1 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -1,12 +1,11 @@ import { css, cx } from '@emotion/css'; -import { useState } from 'react'; +import { useId, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes/ThemeContext'; -import { clearButtonStyles } from '../Button/Button'; -import { Icon } from '../Icon/Icon'; +import { IconButton } from '../IconButton/IconButton'; const getStyles = (theme: GrafanaTheme2) => ({ collapse: css({ @@ -74,25 +73,21 @@ const getStyles = (theme: GrafanaTheme2) => ({ }, }), header: css({ + cursor: 'pointer', label: 'collapse__header', - padding: theme.spacing(1, 2, 1, 2), + padding: theme.spacing(1), display: 'flex', + gap: theme.spacing(1), }), - headerCollapsed: css({ - label: 'collapse__header--collapsed', - padding: theme.spacing(1, 2, 1, 2), + button: css({ + marginRight: 0, }), headerLabel: css({ label: 'collapse__header-label', fontWeight: theme.typography.fontWeightMedium, - marginRight: theme.spacing(1), fontSize: theme.typography.size.md, display: 'flex', - flex: '0 0 100%', - }), - icon: css({ - label: 'collapse__icon', - margin: theme.spacing(0.25, 1, 0, -1), + flex: 1, }), }); @@ -103,12 +98,12 @@ export interface Props { label: React.ReactNode; /** Indicates loading state of the content */ loading?: boolean; - /** Toggle collapsed header icon */ - collapsible?: boolean; /** Callback for the toggle functionality */ onToggle?: (isOpen: boolean) => void; /** Additional class name for the root element */ className?: string; + /** @deprecated this prop is no longer used and will be removed in Grafana 13 */ + collapsible?: boolean; } export const ControlledCollapse = ({ isOpen, onToggle, ...otherProps }: React.PropsWithChildren) => { @@ -116,7 +111,6 @@ export const ControlledCollapse = ({ isOpen, onToggle, ...otherProps }: React.Pr return ( { setOpen(!open); @@ -133,35 +127,38 @@ export const ControlledCollapse = ({ isOpen, onToggle, ...otherProps }: React.Pr * * https://developers.grafana.com/ui/latest/index.html?path=/docs/layout-collapse--docs */ -export const Collapse = ({ - isOpen, - label, - loading, - collapsible, - onToggle, - className, - children, -}: React.PropsWithChildren) => { - const buttonStyles = useStyles2(clearButtonStyles); +export const Collapse = ({ isOpen, label, loading, onToggle, className, children }: React.PropsWithChildren) => { const style = useStyles2(getStyles); + const labelId = useId(); + const contentId = useId(); + const onClickToggle = () => { if (onToggle) { onToggle(!isOpen); } }; - const panelClass = cx([style.collapse, className]); - const loaderClass = loading ? cx([style.loader, style.loaderActive]) : cx([style.loader]); - const headerClass = collapsible ? cx([style.header]) : cx([style.headerCollapsed]); + const loaderClass = loading ? cx([style.loader, style.loaderActive]) : style.loader; return (
- + {/* the inner button handles keyboard a11y. this is a convenience for mouse users */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ +
+ {label} +
+
{isOpen && ( -
+
{children}
diff --git a/packages/grafana-ui/src/components/IconButton/IconButton.tsx b/packages/grafana-ui/src/components/IconButton/IconButton.tsx index d4d77c33fab..f2d44eb2fa1 100644 --- a/packages/grafana-ui/src/components/IconButton/IconButton.tsx +++ b/packages/grafana-ui/src/components/IconButton/IconButton.tsx @@ -41,7 +41,12 @@ interface BasePropsWithAriaLabel extends BaseProps { ['aria-label']: string; } -export type Props = BasePropsWithTooltip | BasePropsWithAriaLabel; +interface BasePropsWithAriaLabelledBy extends BaseProps { + /** Reference to an element id that labels the button. No tooltip will be set in this case. */ + ['aria-labelledby']: string; +} + +export type Props = BasePropsWithTooltip | BasePropsWithAriaLabel | BasePropsWithAriaLabelledBy; /** * This component looks just like an icon but behaves like a button. diff --git a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx index 4f2c364859a..bf2a253d7e8 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx @@ -385,7 +385,6 @@ function TargetFolderNotEmptyWarning({ targetFolderRules }: { targetFolderRules: )} isOpen={showTargetRules} onToggle={toggleShowTargetRules} - collapsible={true} > diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx index bfdb5b3303c..d88d446642d 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx @@ -150,7 +150,6 @@ const ImportToGMARules = () => { label={t('alerting.import-to-gma.additional-settings', 'Additional settings')} isOpen={optionsShowing} onToggle={toggleOptions} - collapsible={true} > diff --git a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx index dc87060c03d..61ce391d779 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx @@ -130,7 +130,6 @@ export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmi /> { })} { setIsLabelDescOpen(!isLabelDescOpen); @@ -201,7 +200,6 @@ export const CorrelationHelper = ({ exploreId, correlations }: Props) => { { setIsTransformOpen(!isTransformOpen); diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index 6ff6c9a248c..13bc78b5849 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -24,7 +24,7 @@ import { import { t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { Collapse } from '@grafana/ui'; +import { PanelChrome } from '@grafana/ui'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { ExploreItemState } from 'app/types/explore'; @@ -309,7 +309,7 @@ class LogsContainer extends PureComponent - + {(controls) => ( )} - + } isOpen={enabled} - collapsible={true} onToggle={onToggleLogsSampleCollapse} > diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx index d9909845984..d48dabdf1fa 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx @@ -151,7 +151,7 @@ export const SpanFilters = memo((props: SpanFilterProps) => { return (
- + diff --git a/public/app/features/logs/components/panel/LogLineContext.tsx b/public/app/features/logs/components/panel/LogLineContext.tsx index 59a34bb9cae..1fab2336c3d 100644 --- a/public/app/features/logs/components/panel/LogLineContext.tsx +++ b/public/app/features/logs/components/panel/LogLineContext.tsx @@ -329,7 +329,6 @@ export const LogLineContext = memo(
{getLogRowContextUi(log, updateResults)}
)} setShowLog(!showLog)} className={styles.referenceLogLine} diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx index 240bc546313..36bdbf2bdde 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -131,7 +131,6 @@ export const LogLineDetailsComponent = memo( handleToggle('logLineOpen', isOpen)} > @@ -140,7 +139,6 @@ export const LogLineDetailsComponent = memo( {displayedFields.length > 0 && setDisplayedFields && ( handleToggle('displayedFieldsOpen', isOpen)} > @@ -151,7 +149,6 @@ export const LogLineDetailsComponent = memo( handleToggle('linksOpen', isOpen)} > @@ -161,7 +158,6 @@ export const LogLineDetailsComponent = memo( {trace && ( handleToggle('traceOpen', isOpen)} > @@ -174,7 +170,6 @@ export const LogLineDetailsComponent = memo( className={styles.collapsable} key={'fields'} label={t('logs.log-line-details.fields-section', 'Fields')} - collapsible isOpen={fieldsOpen} onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)} > @@ -186,7 +181,6 @@ export const LogLineDetailsComponent = memo( className={styles.collapsable} key={group} label={group} - collapsible isOpen={store.getBool(`${logOptionsStorageKey}.log-details.${groupOptionName(group)}`, true)} onToggle={(isOpen: boolean) => handleToggle(groupOptionName(group), isOpen)} > @@ -199,7 +193,6 @@ export const LogLineDetailsComponent = memo( className={styles.collapsable} key={'fields'} label={t('logs.log-line-details.fields-section', 'Fields')} - collapsible isOpen={fieldsOpen} onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)} > diff --git a/public/app/features/visualization/data-hover/DataHoverRows.tsx b/public/app/features/visualization/data-hover/DataHoverRows.tsx index d99fcfd04f9..0c9ca3759d7 100644 --- a/public/app/features/visualization/data-hover/DataHoverRows.tsx +++ b/public/app/features/visualization/data-hover/DataHoverRows.tsx @@ -38,7 +38,6 @@ export const DataHoverRows = ({ layers, activeTabIndex }: Props) => { return shouldDisplayCollapse ? ( { diff --git a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx index 5116a7b86fb..7ed4304b881 100644 --- a/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/LogsQueryEditor/AzureCheatSheet.tsx @@ -186,7 +186,6 @@ const AzureCheatSheet = (props: AzureCheatSheetProps) => { return ( setAreDropdownsOpen({ ...areDropdownsOpen, [category]: isOpen })} key={category} diff --git a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx index d16cfff82e9..f9d5cee1d55 100644 --- a/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/ResourcePicker/AdvancedMulti.tsx @@ -19,7 +19,6 @@ const AdvancedMulti = ({ resources, onChange, renderAdvanced }: ResourcePickerPr return (
setIsAdvancedOpen(!isAdvancedOpen)} diff --git a/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx b/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx index 86fcbd00857..082a8eaf44a 100644 --- a/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/CheatSheet/LogsCheatSheet.tsx @@ -80,7 +80,7 @@ interface CollapseProps { const CheatSheetCollapse = (props: CollapseProps) => { const [isOpen, setIsOpen] = useState(false); return ( - + {props.children} ); diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx index 8a76ff319f4..3a7ffccdbb7 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/configuration/ConfigurationEditor.tsx @@ -113,7 +113,7 @@ export const PostgresConfigEditor = (props: DataSourcePluginOptionsEditorProps

- setIsOpen((x) => !x)}> + setIsOpen((x) => !x)}> The database user should only be granted SELECT permissions on the specified database & tables you want to query.
Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx index c6c3dad3719..df27a4b83d8 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryOptionGroup.tsx @@ -19,7 +19,6 @@ export function QueryOptionGroup({ title, children, collapsedInfo }: Props) {

{ window.localStorage.setItem(IS_LOKI_LOG_CONTEXT_UI_OPEN, (!isOpen).toString()); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx index 9be0cc98ec7..7841d9f1edc 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx @@ -99,7 +99,6 @@ export const QueryPatternsModal = (props: Props) => { key={patternType} label={`${capitalize(patternType)} query starters`} isOpen={openTabs.includes(patternType)} - collapsible={true} onToggle={() => setOpenTabs((tabs) => // close tab if it's already open, otherwise open it diff --git a/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx index bf0acc0731d..46cd7d8b3e3 100644 --- a/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/mysql/configuration/ConfigurationEditor.tsx @@ -60,7 +60,7 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps - setIsOpen((x) => !x)}> + setIsOpen((x) => !x)}> The database user should only be granted SELECT permissions on the specified database & tables you want to query.
Grafana does not validate that queries are safe so queries can contain any SQL statement. For example, diff --git a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx index 4541016132b..dd68fa412ab 100644 --- a/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx +++ b/public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx @@ -25,7 +25,6 @@ export function QueryOptionGroup({ title, children, collapsedInfo, queryStats, o
Date: Thu, 30 Oct 2025 15:31:57 +0100 Subject: [PATCH 017/555] Codegen/CI: Update makefile to also verify gen-cue (#113211) * update makefile to also verify gen-cue * update for jsonnet --- Makefile | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 5bf0150e047..ab2a0ae2c55 100644 --- a/Makefile +++ b/Makefile @@ -150,7 +150,19 @@ i18n-extract: i18n-extract-enterprise ##@ Building .PHONY: gen-cue -gen-cue: ## Do all CUE/Thema code generation +gen-cue: do-gen-cue + @if [ -n "$$CODEGEN_VERIFY" ]; then \ + echo "Verifying generated code is up to date..."; \ + if ! git diff --quiet; then \ + echo "Error: Generated cue files are not up to date. Please run 'make gen-cue' to regenerate."; \ + git diff --name-only; \ + exit 1; \ + fi; \ + echo "Generated cue files are up to date."; \ + fi + +.PHONY: do-gen-cue +do-gen-cue: ## Do all CUE/Thema code generation @echo "generate code from .cue files" go generate ./kinds/gen.go go generate ./public/app/plugins/gen.go @@ -223,10 +235,24 @@ fix-cue: $(cue) fix kinds/**/*.cue $(cue) fix public/app/plugins/**/**/*.cue + .PHONY: gen-jsonnet -gen-jsonnet: +gen-jsonnet: do-gen-jsonnet + @if [ -n "$$CODEGEN_VERIFY" ]; then \ + echo "Verifying generated code is up to date..."; \ + if ! git diff --quiet; then \ + echo "Error: Generated jsonnet files are not up to date. Please run 'make gen-jsonnet' to regenerate."; \ + git diff --name-only; \ + exit 1; \ + fi; \ + echo "Generated jsonnet files are up to date."; \ + fi + +.PHONY: do-gen-jsonnet +do-gen-jsonnet: go generate ./devenv/jsonnet + .PHONY: update-workspace update-workspace: gen-go @echo "updating workspace" From 20ec8ee61cec32d756d1faf2bfd3481342e3e655 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Thu, 30 Oct 2025 10:37:13 -0400 Subject: [PATCH 018/555] useProvisionedRequestHandler: reset ref when a new request is loading (#113196) --- .../provisioning/hooks/useProvisionedRequestHandler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/public/app/features/provisioning/hooks/useProvisionedRequestHandler.ts b/public/app/features/provisioning/hooks/useProvisionedRequestHandler.ts index 528e3ee49be..a5a09fb4ce3 100644 --- a/public/app/features/provisioning/hooks/useProvisionedRequestHandler.ts +++ b/public/app/features/provisioning/hooks/useProvisionedRequestHandler.ts @@ -90,6 +90,11 @@ export function useProvisionedRequestHandler({ workflow, }; + // Reset handler guard when a new request starts loading + if (request.isLoading) { + hasHandled.current = false; + } + if (request.isError) { hasHandled.current = true; handlers.onError?.(request.error, info); From d6bcca2f7e0c90222bc141dec4b101a25179e357 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:14:37 +0000 Subject: [PATCH 019/555] Alerting: Hide metadata if grouping by folder (#113216) * Alerting: Hide metadata if grouping by folder * resolve comments * resolve comments 2 --- .../alerting/unified/triage/Workbench.tsx | 30 +++++++++++++++---- .../unified/triage/rows/AlertRuleRow.tsx | 23 +++++++++----- .../unified/triage/scene/Workbench.tsx | 2 +- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx index ceb4196ad7f..86ff6c03a1e 100644 --- a/public/app/features/alerting/unified/triage/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -23,7 +23,7 @@ import { Domain, Filter, WorkbenchRow } from './types'; type WorkbenchProps = { domain: Domain; data: WorkbenchRow[]; - groupBy?: string[]; // @TODO proper type + groupBy?: string[]; filterBy?: Filter[]; queryRunner: SceneQueryRunner; }; @@ -36,13 +36,30 @@ function renderWorkbenchRow( leftColumnWidth: number, domain: Domain, key: React.Key, + enableFolderMeta: boolean, depth = 0 ): React.ReactElement { if (row.type === 'alertRule') { - return ; + return ( + + ); } else { const children = row.rows.map((childRow, childIndex) => - renderWorkbenchRow(childRow, leftColumnWidth, domain, `${key}-${generateRowKey(childRow, childIndex)}`, depth + 1) + renderWorkbenchRow( + childRow, + leftColumnWidth, + domain, + `${key}-${generateRowKey(childRow, childIndex)}`, + enableFolderMeta, + depth + 1 + ) ); // Check if this is a grafana_folder group and use FolderGroupRow @@ -99,11 +116,14 @@ function renderWorkbenchRow( │ │ │ │ └─────────────────────────┘ └───────────────────────────────────┘ */ -export function Workbench({ domain, data, queryRunner }: WorkbenchProps) { +export function Workbench({ domain, data, queryRunner, groupBy }: WorkbenchProps) { const styles = useStyles2(getStyles); const isLoading = !queryRunner.isDataReadyToDisplay(); const [pageIndex, setPageIndex] = useState(1); + + // Calculate once: show folder metadata only if not grouping by grafana_folder + const enableFolderMeta = !groupBy?.includes('grafana_folder'); // splitter for template and payload editor const splitter = useSplitter({ direction: 'row', @@ -151,7 +171,7 @@ export function Workbench({ domain, data, queryRunner }: WorkbenchProps) { ) : ( dataSlice.map((row, index) => { const rowKey = generateRowKey(row, index); - return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey); + return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey, enableFolderMeta); }) )} {hasMore && setPageIndex((prevIndex) => prevIndex + 1)} />} diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx index 539dc2c4ed1..965e9b5ce31 100644 --- a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -16,9 +16,16 @@ interface AlertRuleRowProps { leftColumnWidth: number; rowKey: React.Key; depth?: number; + enableFolderMeta?: boolean; } -export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertRuleRowProps) => { +export const AlertRuleRow = ({ + row, + leftColumnWidth, + rowKey, + depth = 0, + enableFolderMeta = true, +}: AlertRuleRowProps) => { const { ruleUID, folder, title } = row.metadata; const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -45,12 +52,14 @@ export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertR /> } metadata={ - - - - {folder} - - + enableFolderMeta ? ( + + + + {folder} + + + ) : undefined } content={} depth={depth} diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx index f202a5cd0a0..d83908c2e91 100644 --- a/public/app/features/alerting/unified/triage/scene/Workbench.tsx +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -34,7 +34,7 @@ export function WorkbenchRenderer() { const { data } = runner.useState(); const rows = data ? convertToWorkbenchRows(data, groupByKeys) : []; - return ; + return ; } type DataPoint = Record, string> & Record; From 44beedd09a50530a81c9afbea1eff190b6bfa6b9 Mon Sep 17 00:00:00 2001 From: Misi Date: Thu, 30 Oct 2025 16:50:40 +0100 Subject: [PATCH 020/555] IAM: Handle NULL external_uid, is_provisioned correctly for Teams (#113219) * Handle NULL external_uid correctly with MySQL * Add NULL handling to is_provisioned column --- pkg/registry/apis/iam/legacy/team.go | 21 ++++++++++---- pkg/tests/apis/iam/team_integration_test.go | 31 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pkg/registry/apis/iam/legacy/team.go b/pkg/registry/apis/iam/legacy/team.go index 51e68506fbd..874dea3dff9 100644 --- a/pkg/registry/apis/iam/legacy/team.go +++ b/pkg/registry/apis/iam/legacy/team.go @@ -2,6 +2,7 @@ package legacy import ( "context" + "database/sql" "errors" "fmt" "time" @@ -129,18 +130,18 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo, return nil, fmt.Errorf("expected non zero orgID") } - sql, err := s.sql(ctx) + sqlConn, err := s.sql(ctx) if err != nil { return nil, err } - req := newListTeams(sql, &query) + req := newListTeams(sqlConn, &query) q, err := sqltemplate.Execute(sqlQueryTeamsTemplate, req) if err != nil { return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeamsTemplate.Name(), err) } - rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) + rows, err := sqlConn.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) defer func() { if rows != nil { _ = rows.Close() @@ -155,11 +156,21 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo, var lastID int64 for rows.Next() { t := team.Team{} - err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &t.ExternalUID, &t.IsProvisioned, &t.Created, &t.Updated) + var externalUID sql.NullString + var isProvisioned sql.NullBool + err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &externalUID, &isProvisioned, &t.Created, &t.Updated) if err != nil { return res, err } + if externalUID.Valid { + t.ExternalUID = externalUID.String + } + + if isProvisioned.Valid { + t.IsProvisioned = isProvisioned.Bool + } + lastID = t.ID res.Teams = append(res.Teams, t) if len(res.Teams) > int(query.Pagination.Limit)-1 { @@ -170,7 +181,7 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo, } if query.UID == "" { - res.RV, err = sql.GetResourceVersion(ctx, "team", "updated") + res.RV, err = sqlConn.GetResourceVersion(ctx, "team", "updated") } return res, err diff --git a/pkg/tests/apis/iam/team_integration_test.go b/pkg/tests/apis/iam/team_integration_test.go index e8bb115dfb7..16d27456e03 100644 --- a/pkg/tests/apis/iam/team_integration_test.go +++ b/pkg/tests/apis/iam/team_integration_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "time" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -222,6 +223,36 @@ func doTeamCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { require.Equal(t, createdUID, fetched.GetName()) require.Equal(t, "default", fetched.GetNamespace()) + + // Cleanup + err = teamClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("should list teams correctly", func(t *testing.T) { + ctx := context.Background() + + teamClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrTeams, + }) + + // For ensuring that it is able to list a team which has external_uid = null and is_provisioned = null + // only matters when legacy storage is involved + env := helper.GetEnv() + res, err := env.SQLStore.GetSqlxSession().Exec(ctx, "INSERT INTO team (org_id, uid, name, email, is_provisioned, external_uid, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + helper.Org1.Admin.Identity.GetOrgID(), "t000000001", "List Team 1", "list-team-1@example.com", nil, nil, time.Now(), time.Now()) + require.NoError(t, err) + require.NotNil(t, res) + + list, err := teamClient.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, list) + + // Cleanup + _, err = env.SQLStore.GetSqlxSession().Exec(ctx, "DELETE FROM team WHERE uid = ?", "t000000001") + require.NoError(t, err) }) } From 72e244c1e7117a03400429330ae1dac354a169c1 Mon Sep 17 00:00:00 2001 From: Jay Clifford <45856600+Jayclifford345@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:57:16 +0000 Subject: [PATCH 021/555] fix(nav): Add tooltip to help button (#113225) Co-authored-by: Jack Baldry --- .../AppChrome/TopBar/HelpTopBarButton.tsx | 15 ++++++++++++++- public/locales/en-US/grafana.json | 7 ++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx b/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx index 62aed5080ef..02215cda4fd 100644 --- a/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx +++ b/public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx @@ -34,7 +34,12 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen } if (isSmallScreen || !enrichedHelpNode.hideFromTabs || interactiveLearningPluginId === undefined) { return ( } placement="bottom-end"> - + ); } @@ -48,6 +53,14 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen } icon="question-circle" aria-label={t('navigation.help.aria-label', 'Help')} className={isOpen ? styles.helpButtonActive : undefined} + tooltip={ + isOpen + ? t( + 'navigation.help.interactive-learning.close-tooltip', + 'Close interactive learning, help, and documentation' + ) + : t('navigation.help.interactive-learning.open-tooltip', 'Open interactive learning, help, and documentation') + } onClick={() => { if (isOpen) { setDockedComponentId(undefined); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fb177e68314..c1736af9a50 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -10597,7 +10597,12 @@ } }, "help": { - "aria-label": "Help" + "aria-label": "Help", + "interactive-learning": { + "close-tooltip": "Close interactive learning, help, and documentation", + "open-tooltip": "Open interactive learning, help, and documentation" + }, + "tooltip": "Get help and useful links" }, "invite-user": { "invite-button": "Invite", From 7fbe2e5962c84246f95ecce23a6bb9b1a8223c2c Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Thu, 30 Oct 2025 09:02:09 -0700 Subject: [PATCH 022/555] PanelTimeSettings: Update wording (#113176) * PanelTimeSettings: Update wording * Update wording --- .../panel-timerange/PanelTimeRangeDrawer.tsx | 18 ++++++++---------- public/locales/en-US/grafana.json | 16 ++++++++-------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRangeDrawer.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRangeDrawer.tsx index 215a5fba5d4..234b2570707 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRangeDrawer.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRangeDrawer.tsx @@ -100,17 +100,17 @@ export class PanelTimeRangeDrawer extends SceneObjectBase @@ -129,7 +129,7 @@ export class PanelTimeRangeDrawer extends SceneObjectBase - - Time window comparison - + Time comparison @@ -172,10 +170,10 @@ export class PanelTimeRangeDrawer extends SceneObjectBase Date: Thu, 30 Oct 2025 09:12:04 -0700 Subject: [PATCH 023/555] Geomap: Move beta layers to GA (#113186) --- .../visualizations/geomap/index.md | 30 +++++-------------- .../panel/geomap/layers/data/networkLayer.tsx | 2 -- .../panel/geomap/layers/data/photosLayer.tsx | 2 -- .../panel/geomap/layers/data/routeLayer.tsx | 2 -- 4 files changed, 7 insertions(+), 29 deletions(-) diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md index 241622708db..fe941617bce 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/geomap/index.md @@ -218,18 +218,14 @@ There are seven map layer types to choose from in a geomap. - [Heatmap](#heatmap-layer) visualizes a heatmap of the data. - [GeoJSON](#geojson-layer) renders static data from a GeoJSON file. - [Night / Day](#night--day-layer) renders a night / day region. -- [Route (Beta)](#route-layer-beta) render data points as a route. -- [Photos (Beta)](#photos-layer-beta) renders a photo at each data point. -- [Network (Beta)](#network-layer-beta) visualizes a network graph from the data. +- [Route](#route-layer) render data points as a route. +- [Photos](#photos-layer) renders a photo at each data point. +- [Network](#network-layer) visualizes a network graph from the data. - [Open Street Map](#open-street-map-layer) adds a map from a collaborative free geographic world database. - [CARTO basemap](#carto-basemap-layer) adds a layer from CARTO Raster basemaps. - [ArcGIS MapServer](#arcgis-mapserver-layer) adds a layer from an ESRI ArcGIS MapServer. - [XYZ Tile layer](#xyz-tile-layer) adds a map from a generic tile layer. -{{< admonition type="note" >}} -Beta is equivalent to the [public preview](/docs/release-life-cycle/) release stage. -{{< /admonition >}} - There are also two experimental (or alpha) layer types. - **Icon at last point (alpha)** renders an icon at the last data point. @@ -361,11 +357,7 @@ The Night / Day layer displays night and day regions based on the current time r [Extensions for OpenLayers - DayNight](https://viglino.github.io/ol-ext/examples/layer/map.daynight.html) -#### Route layer (Beta) - -{{< admonition type="caution" >}} -The Route layer is currently in [public preview](/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. -{{< /admonition >}} +#### Route layer The Route layer renders data points as a route. @@ -390,11 +382,7 @@ The layer can also render a route with arrows. [Extensions for OpenLayers - Flow Line Style](http://viglino.github.io/ol-ext/examples/style/map.style.gpxline.html) -#### Photos layer (Beta) - -{{< admonition type="caution" >}} -The Photos layer is currently in [public preview](/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. -{{< /admonition >}} +#### Photos layer The Photos layer renders a photo at each data point. @@ -417,11 +405,7 @@ The Photos layer renders a photo at each data point. [Extensions for OpenLayers - Image Photo Style](http://viglino.github.io/ol-ext/examples/style/map.style.photo.html) -#### Network layer (Beta) - -{{< admonition type="caution" >}} -The Network layer is currently in [public preview](/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. -{{< /admonition >}} +#### Network layer The Network layer renders a network graph. This layer supports the same [data format supported by the node graph visualization](ref:data-format) with the addition of [geospatial data](#location-mode) included in the nodes data. The geospatial data is used to locate and render the nodes on the map. @@ -709,7 +693,7 @@ Displays debug information in the upper right corner. This can be useful for deb #### Tooltip -Tooltips are supported for the **Markers**, **Heatmap**, **Photos** (beta) layers. +Tooltips are supported for the **Markers**, **Heatmap**, **Photos** layers. For these layer types, choose from the following tooltip options: - **None** displays tooltips only when a data point is clicked. diff --git a/public/app/plugins/panel/geomap/layers/data/networkLayer.tsx b/public/app/plugins/panel/geomap/layers/data/networkLayer.tsx index e6375e79159..b4b70f77724 100644 --- a/public/app/plugins/panel/geomap/layers/data/networkLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/networkLayer.tsx @@ -18,7 +18,6 @@ import { EventBus, DataFrame, Field, - PluginState, } from '@grafana/data'; import { TextDimensionMode } from '@grafana/schema'; import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource'; @@ -69,7 +68,6 @@ export const networkLayer: MapLayerRegistryItem = { isBaseMap: false, showLocation: true, hideOpacity: true, - state: PluginState.beta, /** * Function that configures transformation and returns a transformer diff --git a/public/app/plugins/panel/geomap/layers/data/photosLayer.tsx b/public/app/plugins/panel/geomap/layers/data/photosLayer.tsx index 86932984cc2..c6e2ad7e402 100644 --- a/public/app/plugins/panel/geomap/layers/data/photosLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/photosLayer.tsx @@ -9,7 +9,6 @@ import { PanelData, GrafanaTheme2, EventBus, - PluginState, FieldType, Field, MapLayerOptions, @@ -71,7 +70,6 @@ export const photosLayer: MapLayerRegistryItem = { isBaseMap: false, showLocation: true, hideOpacity: true, - state: PluginState.beta, /** * Function that configures transformation and returns a transformer diff --git a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx index 16fd90076bd..ce220dd57f9 100644 --- a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx @@ -14,7 +14,6 @@ import { MapLayerRegistryItem, PanelData, GrafanaTheme2, - PluginState, EventBus, DataHoverEvent, DataHoverClearEvent, @@ -79,7 +78,6 @@ export const routeLayer: MapLayerRegistryItem = { description: 'Render data points as a route', isBaseMap: false, showLocation: true, - state: PluginState.beta, /** * Function that configures transformation and returns a transformer From 8d5e5e2eadb2365308f8c17f5804735b29146132 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Thu, 30 Oct 2025 12:17:06 -0400 Subject: [PATCH 024/555] SaveDashboardAsForm: Adjust form field spacing (#113243) --- .../saving/SaveDashboardAsForm.tsx | 116 +++++++++--------- 1 file changed, 59 insertions(+), 57 deletions(-) diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index 896c2fa3474..4d90a871440 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -147,64 +147,66 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { return (
onSave(false))}> - } - invalid={!!errors.title} - error={errors.title?.message} - > - - - } - invalid={!!errors.description} - error={errors.description?.message} - > -