From a1e59a92b02168442481bc1efbea48f96c8986d1 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Wed, 12 Feb 2025 15:12:11 +0000 Subject: [PATCH 01/78] Alerting: Allow collapsing of rule sections and fix Grafana configure link (#100290) --- .../components/DataSourceSection.tsx | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx index 9df0aa641b3..11f4f5cbf01 100644 --- a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx +++ b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx @@ -1,14 +1,16 @@ import { css } from '@emotion/css'; import { PropsWithChildren, ReactNode } from 'react'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; +import { IconButton, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; -import { RulesSourceIdentifier } from 'app/types/unified-alerting'; +import { GrafanaRulesSourceSymbol, RulesSourceIdentifier } from 'app/types/unified-alerting'; import { RulesSourceApplication } from 'app/types/unified-alerting-dto'; import { Spacer } from '../../components/Spacer'; import { WithReturnButton } from '../../components/WithReturnButton'; +import { isAdmin } from '../../utils/misc'; import { DataSourceIcon } from './Namespace'; import { LoadingIndicator } from './RuleGroup'; @@ -32,7 +34,17 @@ export const DataSourceSection = ({ description = null, }: DataSourceSectionProps) => { const styles = useStyles2(getStyles); - + const [isCollapsed, toggleCollapsed] = useToggle(false); + const configureLink = (() => { + if (uid === GrafanaRulesSourceSymbol) { + const userIsAdmin = isAdmin(); + if (!userIsAdmin) { + return; + } + return '/alerting/admin'; + } + return `/connections/datasources/edit/${String(uid)}`; + })(); return (
@@ -41,7 +53,13 @@ export const DataSourceSection = ({
{loader ?? ( + {application && } + {name} @@ -52,19 +70,21 @@ export const DataSourceSection = ({ )} - - Configure - - } - /> + {configureLink && ( + + Configure + + } + /> + )} )}
-
{children}
+ {!isCollapsed &&
{children}
}
); From 1f6142dd8fa0d733c3f8da389b4e7810ce869747 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Wed, 12 Feb 2025 15:14:43 +0000 Subject: [PATCH 02/78] Chore: Remove betterer:merge and revert previous changes to betterer:stats (#98609) --- package.json | 3 +-- ...reportBettererStats.mjs => reportBettererStats.ts} | 11 +++++------ scripts/cli/tsconfig.json | 5 ++++- 3 files changed, 10 insertions(+), 9 deletions(-) rename scripts/cli/{reportBettererStats.mjs => reportBettererStats.ts} (74%) diff --git a/package.json b/package.json index 189fc00835e..45e0a3fc92d 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,7 @@ "ci:test-frontend": "yarn run test:ci", "i18n:stats": "node ./scripts/cli/reportI18nStats.mjs", "betterer": "betterer --tsconfig ./scripts/cli/tsconfig.json", - "betterer:merge": "betterer merge --tsconfig ./scripts/cli/tsconfig.json", - "betterer:stats": "node ./scripts/cli/reportBettererStats.mjs", + "betterer:stats": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/reportBettererStats.ts", "betterer:issues": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/generateBettererIssues.ts", "plugin:build": "nx run-many -t build --projects='tag:scope:plugin'", "plugin:build:commit": "nx run-many -t build:commit --projects='tag:scope:plugin'", diff --git a/scripts/cli/reportBettererStats.mjs b/scripts/cli/reportBettererStats.ts similarity index 74% rename from scripts/cli/reportBettererStats.mjs rename to scripts/cli/reportBettererStats.ts index 0f1eda1d646..135f98c7752 100644 --- a/scripts/cli/reportBettererStats.mjs +++ b/scripts/cli/reportBettererStats.ts @@ -1,8 +1,7 @@ -// @ts-check import { betterer } from '@betterer/betterer'; -import _ from 'lodash'; +import { camelCase } from 'lodash'; -function logStat(name, value) { +function logStat(name: string, value: number) { // Note that this output format must match the parsing in ci-frontend-metrics.sh // which expects the two values to be separated by a space console.log(`${name} ${value}`); @@ -13,11 +12,11 @@ async function main() { for (const testResults of results.resultSummaries) { const countByMessage = {}; - const name = _.camelCase(testResults.name); + const name = camelCase(testResults.name); Object.values(testResults.details) .flatMap((v) => v) .forEach((detail) => { - const message = _.camelCase(detail.message); + const message = camelCase(detail.message); const metricName = `${name}_${message}`; if (metricName in countByMessage) { countByMessage[metricName]++; @@ -26,7 +25,7 @@ async function main() { } }); - for (const [metricName, count] of Object.entries(countByMessage)) { + for (const [metricName, count] of Object.entries(countByMessage)) { logStat(metricName, count); } } diff --git a/scripts/cli/tsconfig.json b/scripts/cli/tsconfig.json index 9ad59a649d1..0d18e875c5e 100644 --- a/scripts/cli/tsconfig.json +++ b/scripts/cli/tsconfig.json @@ -6,6 +6,9 @@ "extends": "../../tsconfig.json", "ts-node": { "transpileOnly": true, - "swc": true + "swc": true, + "compilerOptions": { + "module": "commonjs" + } } } From 6e00954bb170b3900f6c2053326c7a3b25dc6a10 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Wed, 12 Feb 2025 16:44:49 +0100 Subject: [PATCH 03/78] Devenv: Use newer label syntax (#100507) --- devenv/docker/blocks/prometheus_random_data/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv/docker/blocks/prometheus_random_data/Dockerfile b/devenv/docker/blocks/prometheus_random_data/Dockerfile index c4ebc8cb0ec..41f90677409 100644 --- a/devenv/docker/blocks/prometheus_random_data/Dockerfile +++ b/devenv/docker/blocks/prometheus_random_data/Dockerfile @@ -7,7 +7,7 @@ RUN CGO_ENABLED=0 GOOS=linux go install -tags netgo -ldflags '-w' github.com/pro # Final image. FROM scratch -LABEL maintainer "The Prometheus Authors " +LABEL maintainer="The Prometheus Authors " COPY --from=builder /go/bin/random . EXPOSE 8080 ENTRYPOINT ["/random"] From f6f50f7693628878c2a1ca76114bdc6ca9f54ffb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:07:38 +0100 Subject: [PATCH 04/78] Update scenes to v6 (#100445) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 4 +-- yarn.lock | 69 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 45e0a3fc92d..4d638dc7b9b 100644 --- a/package.json +++ b/package.json @@ -275,8 +275,8 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "5.42.0", - "@grafana/scenes-react": "5.42.0", + "@grafana/scenes": "6.0.1", + "@grafana/scenes-react": "6.0.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index d5061c52875..f818433fbda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3814,11 +3814,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:5.42.0": - version: 5.42.0 - resolution: "@grafana/scenes-react@npm:5.42.0" +"@grafana/scenes-react@npm:6.0.1": + version: 6.0.1 + resolution: "@grafana/scenes-react@npm:6.0.1" dependencies: - "@grafana/scenes": "npm:5.42.0" + "@grafana/scenes": "npm:6.0.1" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3829,20 +3829,21 @@ __metadata: "@grafana/ui": ^11.0.0 react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/c94db6d57b02be5f960e44dc0c4be46e5e4708ede34f2f4e30934134543db88c8b553aceb89d9b3ae4dbf5d1eab1f48e534eb9a31d5c99b8908f715e45cb6dcf + react-router-dom: ^6.28.0 + checksum: 10/e4ad83cc628f17232fe9c8d74f641c65e2e289c177ce88a6990d00f6bea4e1a091115e7b98200de7bcff14ace0fe20eb816141fe533fee7d2ad5f7f665404d2c languageName: node linkType: hard -"@grafana/scenes@npm:5.42.0": - version: 5.42.0 - resolution: "@grafana/scenes@npm:5.42.0" +"@grafana/scenes@npm:6.0.1": + version: 6.0.1 + resolution: "@grafana/scenes@npm:6.0.1" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" "@tanstack/react-virtual": "npm:^3.9.0" - react-grid-layout: "npm:^1.3.4" - react-use: "npm:^17.5.0" - react-virtualized-auto-sizer: "npm:^1.0.24" + react-grid-layout: "npm:1.3.4" + react-use: "npm:17.5.0" + react-virtualized-auto-sizer: "npm:1.0.24" uuid: "npm:^9.0.0" peerDependencies: "@grafana/data": ">=10.4" @@ -3852,7 +3853,8 @@ __metadata: "@grafana/ui": ">=10.4" react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/3232a499b839a45c8eed924441e4423d1b1d9f6b4ea6725437d357c1afea93d9ab300c46319b1ddab76b0da1197905b22c3abe8095d78c3320b1befb4731bd24 + react-router-dom: ^6.28.0 + checksum: 10/6862e57358ba2e63f139e7f3bb977b19945f67eb070aa2c85c073a55dc460d3ccfeecfee22aea92c660a7632ac997e6cd945f9466b64103436a221979e6e8fcb languageName: node linkType: hard @@ -18149,8 +18151,8 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:5.42.0" - "@grafana/scenes-react": "npm:5.42.0" + "@grafana/scenes": "npm:6.0.1" + "@grafana/scenes-react": "npm:6.0.1" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" @@ -23004,7 +23006,7 @@ __metadata: languageName: node linkType: hard -"nano-css@npm:^5.3.1, nano-css@npm:^5.6.2": +"nano-css@npm:^5.3.1, nano-css@npm:^5.6.1, nano-css@npm:^5.6.2": version: 5.6.2 resolution: "nano-css@npm:5.6.2" dependencies: @@ -27008,6 +27010,31 @@ __metadata: languageName: node linkType: hard +"react-use@npm:17.5.0": + version: 17.5.0 + resolution: "react-use@npm:17.5.0" + dependencies: + "@types/js-cookie": "npm:^2.2.6" + "@xobotyi/scrollbar-width": "npm:^1.9.5" + copy-to-clipboard: "npm:^3.3.1" + fast-deep-equal: "npm:^3.1.3" + fast-shallow-equal: "npm:^1.0.0" + js-cookie: "npm:^2.2.1" + nano-css: "npm:^5.6.1" + react-universal-interface: "npm:^0.6.2" + resize-observer-polyfill: "npm:^1.5.1" + screenfull: "npm:^5.1.0" + set-harmonic-interval: "npm:^1.0.1" + throttle-debounce: "npm:^3.0.1" + ts-easing: "npm:^0.2.0" + tslib: "npm:^2.1.0" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 10/5d81fe0902303d3ed7810cdd56c6cae12b08124a3d4fcbfa3924327105b81447b039ea9d6aff20aac3c13999f949000870a7a2fa29fe20ed844ac26606462fa0 + languageName: node + linkType: hard + "react-use@npm:17.5.1": version: 17.5.1 resolution: "react-use@npm:17.5.1" @@ -27069,7 +27096,17 @@ __metadata: languageName: node linkType: hard -"react-virtualized-auto-sizer@npm:1.0.25, react-virtualized-auto-sizer@npm:^1.0.24, react-virtualized-auto-sizer@npm:^1.0.6": +"react-virtualized-auto-sizer@npm:1.0.24": + version: 1.0.24 + resolution: "react-virtualized-auto-sizer@npm:1.0.24" + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + checksum: 10/02101a340bdbe3e40c49dbc52e524eb7ca18832690e91f045a25675600d7adc0a63e800a4ace6a014132adcdcce0e12a8137971de408427a5a3112d7c87c9f3e + languageName: node + linkType: hard + +"react-virtualized-auto-sizer@npm:1.0.25, react-virtualized-auto-sizer@npm:^1.0.6": version: 1.0.25 resolution: "react-virtualized-auto-sizer@npm:1.0.25" peerDependencies: From 2b054d4154ad174d630dadec49fa26a97d151232 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Wed, 12 Feb 2025 16:11:12 +0000 Subject: [PATCH 05/78] Correct release branch trigger glob (#100496) --- .github/workflows/publish-technical-documentation-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml index bd77c286018..57d779660c5 100644 --- a/.github/workflows/publish-technical-documentation-release.yml +++ b/.github/workflows/publish-technical-documentation-release.yml @@ -3,7 +3,7 @@ name: publish-technical-documentation-release on: push: branches: - - release-v[0-9]+.[0-9]+.[0-9]+ + - release-[0-9]+.[0-9]+.[0-9]+ tags: - v[0-9]+.[0-9]+.[0-9]+ paths: From cfc529cb035d66f89a0482ae847dce6aff29aa50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 12 Feb 2025 17:22:56 +0100 Subject: [PATCH 06/78] Design/Theme: Change dropdown background in dark themes (#100415) * Dropdowns: Change background for Selects/Comboboxs * Update * Update * Review fixes * Update --- .../src/themes/createComponents.ts | 2 +- .../shared/OperationInfoButton.tsx | 4 +-- .../src/components/Cascader/styles.ts | 7 +++--- .../src/components/Combobox/Combobox.tsx | 2 +- .../src/components/Combobox/MultiCombobox.tsx | 2 +- .../components/Combobox/getComboboxStyles.ts | 1 + .../src/components/Select/SelectMenu.tsx | 2 +- .../src/components/Select/getSelectStyles.ts | 1 + .../core/components/TagFilter/TagOption.tsx | 7 +++--- .../components/picker/DataSourceCard.tsx | 25 +++++++++++++------ .../components/picker/DataSourceList.tsx | 1 + .../components/picker/DataSourcePicker.tsx | 4 ++- 12 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts index 70dbcd67cf5..e810a94b86e 100644 --- a/packages/grafana-data/src/themes/createComponents.ts +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -80,7 +80,7 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th input, panel, dropdown: { - background: input.background, + background: colors.background.elevated, }, tooltip: { background: colors.background.elevated, diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx index 69f26d84865..b576188a581 100644 --- a/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationInfoButton.tsx @@ -94,8 +94,8 @@ const getStyles = (theme: GrafanaTheme2) => { return { docBox: css({ overflow: 'hidden', - background: theme.colors.background.primary, - border: `1px solid ${theme.colors.border.strong}`, + background: theme.colors.background.elevated, + border: `1px solid ${theme.colors.border.weak}`, boxShadow: theme.shadows.z3, maxWidth: '600px', padding: theme.spacing(1), diff --git a/packages/grafana-ui/src/components/Cascader/styles.ts b/packages/grafana-ui/src/components/Cascader/styles.ts index 537bb8c4a7e..cef38b62dcc 100644 --- a/packages/grafana-ui/src/components/Cascader/styles.ts +++ b/packages/grafana-ui/src/components/Cascader/styles.ts @@ -72,8 +72,8 @@ export const getCascaderStyles = (theme: GrafanaTheme2) => ({ '.rc-cascader': { '&-menus': { overflow: 'hidden', - background: theme.colors.background.canvas, - border: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.elevated, + border: `none`, borderRadius: theme.shape.radius.default, boxShadow: theme.shadows.z3, whiteSpace: 'nowrap', @@ -128,7 +128,7 @@ export const getCascaderStyles = (theme: GrafanaTheme2) => ({ height: '192px', listStyle: 'none', margin: 0, - padding: 0, + padding: theme.spacing(0.5), borderRight: `1px solid ${theme.colors.border.weak}`, overflow: 'auto', @@ -140,6 +140,7 @@ export const getCascaderStyles = (theme: GrafanaTheme2) => ({ height: theme.spacing(4), lineHeight: theme.spacing(4), padding: theme.spacing(0, 4, 0, 2), + borderRadius: theme.shape.radius.default, cursor: 'pointer', whiteSpace: 'nowrap', overflow: 'hidden', diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 471bb22fe5d..5d6c5320402 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -407,7 +407,7 @@ export const Combobox = (props: ComboboxProps) => })} > {isOpen && ( - + {!asyncError && (
    {rowVirtualizer.getVirtualItems().map((virtualRow) => { diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 7b41ca2ee53..9a90dfd4588 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -344,7 +344,7 @@ export const MultiCombobox = (props: MultiComboboxPro {...getMenuProps({ ref: floatingRef })} > {isOpen && ( - +
      {rowVirtualizer.getVirtualItems().map((virtualRow) => { const startingNewGroup = isNewGroup(options[virtualRow.index], options[virtualRow.index - 1]); diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index f0ea95cacb2..6c788b15cbd 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -46,6 +46,7 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { option: css({ padding: MENU_ITEM_PADDING, cursor: 'pointer', + borderRadius: theme.shape.radius.default, width: '100%', '&:hover': { background: theme.colors.action.hover, diff --git a/packages/grafana-ui/src/components/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Select/SelectMenu.tsx index b557b2e9a65..e19c8d87546 100644 --- a/packages/grafana-ui/src/components/Select/SelectMenu.tsx +++ b/packages/grafana-ui/src/components/Select/SelectMenu.tsx @@ -54,7 +54,7 @@ export const SelectMenu = ({ style={{ maxHeight }} aria-label="Select options menu" > - + {toggleAllOptions && ( { whiteSpace: 'nowrap', cursor: 'pointer', borderLeft: '2px solid transparent', + borderRadius: theme.shape.radius.default, '&:hover': { background: theme.colors.action.hover, diff --git a/public/app/core/components/TagFilter/TagOption.tsx b/public/app/core/components/TagFilter/TagOption.tsx index 986d3f9ec7d..57abca0f942 100644 --- a/public/app/core/components/TagFilter/TagOption.tsx +++ b/public/app/core/components/TagFilter/TagOption.tsx @@ -27,16 +27,17 @@ export const TagOption = ({ data, className, label, isFocused, innerProps }: Opt const getStyles = (theme: GrafanaTheme2) => { return { option: css({ - padding: theme.spacing(1), + padding: theme.spacing(0.5), whiteSpace: 'nowrap', cursor: 'pointer', borderLeft: '2px solid transparent', + borderRadius: theme.shape.radius.default, '&:hover': { - background: theme.colors.background.secondary, + background: theme.colors.action.hover, }, }), optionFocused: css({ - background: theme.colors.background.secondary, + background: theme.colors.action.focus, borderStyle: 'solid', borderTop: 0, borderRight: 0, diff --git a/public/app/features/datasources/components/picker/DataSourceCard.tsx b/public/app/features/datasources/components/picker/DataSourceCard.tsx index 7e14837a32c..84403ae4298 100644 --- a/public/app/features/datasources/components/picker/DataSourceCard.tsx +++ b/public/app/features/datasources/components/picker/DataSourceCard.tsx @@ -41,15 +41,14 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) { return { card: css({ cursor: 'pointer', - backgroundColor: theme.colors.background.primary, - borderBottom: `1px solid ${theme.colors.border.weak}`, + backgroundColor: 'transparent', // Move to list component marginBottom: 0, - // set this to 0 to override the default card radius - // also need to disable our eslint rule - // eslint-disable-next-line @grafana/no-border-radius-literal - borderRadius: 0, padding: theme.spacing(1), + + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, }), heading: css({ width: '100%', @@ -98,7 +97,19 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) { color: theme.colors.border.weak, }), selected: css({ - backgroundColor: theme.colors.background.secondary, + background: theme.colors.action.selected, + + '&::before': { + backgroundImage: theme.colors.gradients.brandVertical, + borderRadius: theme.shape.radius.default, + content: '" "', + display: 'block', + height: '100%', + position: 'absolute', + transform: 'translateX(-50%)', + width: theme.spacing(0.5), + left: 0, + }, }), meta: css({ display: 'block', diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index 11fc0bc90e0..93d8e15f546 100644 --- a/public/app/features/datasources/components/picker/DataSourceList.tsx +++ b/public/app/features/datasources/components/picker/DataSourceList.tsx @@ -141,6 +141,7 @@ function getStyles(theme: GrafanaTheme2, selectedItemCssSelector: string) { container: css({ display: 'flex', flexDirection: 'column', + padding: theme.spacing(0.5), [`${selectedItemCssSelector}`]: { backgroundColor: theme.colors.background.secondary, }, diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.tsx index 47a0366230e..88aebfbafe3 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.tsx @@ -380,8 +380,10 @@ function getStylesPickerContent(theme: GrafanaTheme2) { container: css({ display: 'flex', flexDirection: 'column', - background: theme.colors.background.primary, + background: theme.colors.background.elevated, + borderRadius: theme.shape.radius.default, boxShadow: theme.shadows.z3, + overflow: 'hidden', }), picker: css({ background: theme.colors.background.secondary, From d3de9dbce659d49c358a05515294de10f8fe353d Mon Sep 17 00:00:00 2001 From: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:29:47 -0500 Subject: [PATCH 07/78] ExploreMetrics: Fix escaping of regex metacharacters in label filters (#100513) * fix: don't over-escape label values * test: handling of regex metacharacters in filters --- public/app/features/trails/DataTrail.test.tsx | 70 ++++++++++--------- public/app/features/trails/DataTrail.tsx | 5 ++ 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx index 4d0d0af86c4..ecb928383d2 100644 --- a/public/app/features/trails/DataTrail.test.tsx +++ b/public/app/features/trails/DataTrail.test.tsx @@ -44,14 +44,6 @@ describe('DataTrail', () => { let trail: DataTrail; const preTrailUrl = '/'; - function getFilterVar() { - const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error('getFilterVar failed'); - } - function getStepFilterVar(step: number) { const variable = trail.state.history.state.steps[step].trailState.$variables?.getByName(VAR_FILTERS); if (variable instanceof AdHocFiltersVariable) { @@ -226,12 +218,12 @@ describe('DataTrail', () => { }); it('Should have default empty filter', () => { - expect(getFilterVar().state.filters.length).toBe(0); + expect(getFilterVar(trail).state.filters.length).toBe(0); }); describe('And when changing the filter to zone=a', () => { beforeEach(() => { - getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); + getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); }); it('should add history step', () => { @@ -247,8 +239,8 @@ describe('DataTrail', () => { }); it('Should have filter be updated to "zone=a"', () => { - expect(getFilterVar().state.filters[0].key).toBe('zone'); - expect(getFilterVar().state.filters[0].value).toBe('a'); + expect(getFilterVar(trail).state.filters[0].key).toBe('zone'); + expect(getFilterVar(trail).state.filters[0].value).toBe('a'); }); it('Previous history step should have empty filter', () => { @@ -274,12 +266,12 @@ describe('DataTrail', () => { }); it('Should have filters set back to empty', () => { - expect(getFilterVar().state.filters.length).toBe(0); + expect(getFilterVar(trail).state.filters.length).toBe(0); }); describe('And when changing the filter to zone=b', () => { beforeEach(() => { - getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] }); + getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'b' }] }); }); it('should add history step', () => { @@ -295,8 +287,8 @@ describe('DataTrail', () => { }); it('Should have filter be updated to "zone=b"', () => { - expect(getFilterVar().state.filters[0].key).toBe('zone'); - expect(getFilterVar().state.filters[0].value).toBe('b'); + expect(getFilterVar(trail).state.filters[0].key).toBe('zone'); + expect(getFilterVar(trail).state.filters[0].value).toBe('b'); }); it('Parent history step 1 should still have empty filter', () => { @@ -327,7 +319,7 @@ describe('DataTrail', () => { }); it('Should have filters set back to empty', () => { - expect(getFilterVar().state.filters.length).toBe(0); + expect(getFilterVar(trail).state.filters.length).toBe(0); }); it('History step 1 should still have empty filter', () => { @@ -417,12 +409,12 @@ describe('DataTrail', () => { describe('And filter is added zone=a', () => { beforeEach(() => { - getFilterVar().setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); + getFilterVar(trail).setState({ filters: [{ key: 'zone', operator: '=', value: 'a' }] }); }); it('Filter of trail should be zone=a', () => { - expect(getFilterVar().state.filters[0].key).toBe('zone'); - expect(getFilterVar().state.filters[0].value).toBe('a'); + expect(getFilterVar(trail).state.filters[0].key).toBe('zone'); + expect(getFilterVar(trail).state.filters[0].value).toBe('a'); }); it('Filter of step 2 should be zone=a', () => { @@ -440,7 +432,7 @@ describe('DataTrail', () => { }); it('Filter of trail should be empty', () => { - expect(getFilterVar().state.filters.length).toBe(0); + expect(getFilterVar(trail).state.filters.length).toBe(0); }); }); }); @@ -518,14 +510,6 @@ describe('DataTrail', () => { throw new Error('getOtelGroupLeftVar failed'); } - function getFilterVar() { - const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail); - if (variable instanceof AdHocFiltersVariable) { - return variable; - } - throw new Error('getFilterVar failed'); - } - beforeEach(() => { trail = new DataTrail({ nonPromotedOtelResources, @@ -540,7 +524,7 @@ describe('DataTrail', () => { it('clicking start button should start with OTel off and showing var filters', () => { trail.setState({ startButtonClicked: true }); const otelResourcesHide = getOtelResourcesVar(trail).state.hide; - const varFiltersHide = getFilterVar().state.hide; + const varFiltersHide = getFilterVar(trail).state.hide; expect(otelResourcesHide).toBe(VariableHide.hideVariable); expect(varFiltersHide).toBe(VariableHide.hideLabel); }); @@ -557,7 +541,7 @@ describe('DataTrail', () => { describe('resetting the OTel experience', () => { it('should display with hideLabel var filters and hide VAR_OTEL_AND_METRIC_FILTERS when resetting otel experience', () => { trail.resetOtelExperience(); - expect(getFilterVar().state.hide).toBe(VariableHide.hideLabel); + expect(getFilterVar(trail).state.hide).toBe(VariableHide.hideLabel); expect(getOtelAndMetricsVar(trail).state.hide).toBe(VariableHide.hideVariable); }); @@ -589,7 +573,7 @@ describe('DataTrail', () => { it('should automatically update the var filters when a promoted resource has been selected from VAR_OTEL_AND_METRICS', () => { getOtelAndMetricsVar(trail).setState({ filters: [{ key: 'promoted', operator: '=', value: 'resource' }] }); - const varFilters = getFilterVar().state.filters[0]; + const varFilters = getFilterVar(trail).state.filters[0]; expect(varFilters.key).toBe('promoted'); expect(varFilters.value).toBe('resource'); }); @@ -600,4 +584,26 @@ describe('DataTrail', () => { }); }); }); + + describe('Label filters', () => { + let trail: DataTrail; + + beforeEach(() => { + trail = new DataTrail({}); + }); + + it('should not escape regex metacharacters in label values', () => { + const filterVar = getFilterVar(trail); + filterVar.setState({ filters: [{ key: 'app', operator: '=~', value: '.*end' }] }); // matches app=frontend, app=backend, etc. + expect(filterVar.getValue()).toBe('app=~".*end"'); + }); + }); }); + +function getFilterVar(trail: DataTrail) { + const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail); + if (variable instanceof AdHocFiltersVariable) { + return variable; + } + throw new Error('getFilterVar failed'); +} diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index fa85dd32773..32d353a9e9b 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -669,6 +669,11 @@ function getVariableSet( // since we only support prometheus datasources, this is always true supportsMultiValueOperators: true, allowCustomValue: true, + expressionBuilder: (filters: AdHocVariableFilter[]) => { + return [...getBaseFiltersForMetric(metric), ...filters] + .map((filter) => `${filter.key}${filter.operator}"${filter.value}"`) + .join(','); + }, }), ...getVariablesWithOtelJoinQueryConstant(otelJoinQuery ?? ''), new ConstantVariable({ From 21861867c13fcb0bdb4a41fbf63161f1b288af09 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:51:39 +0100 Subject: [PATCH 08/78] Combobox: Fix broken styles for options (#100536) Add basicOption styles to Combobox --- packages/grafana-ui/src/components/Combobox/Combobox.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 5d6c5320402..62f231975e3 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -416,6 +416,7 @@ export const Combobox = (props: ComboboxProps) => key={`${items[virtualRow.index].value}-${virtualRow.index}`} data-index={virtualRow.index} className={cx( + styles.optionBasic, styles.option, selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected, highlightedIndex === virtualRow.index && styles.optionFocused From e2a101cde3bd03c3b9ad4377a802de277770b920 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 12 Feb 2025 10:19:55 -0700 Subject: [PATCH 09/78] Dashboard history: Track version created timestamp in restore (#100451) --- .../settings/version-history/VersionHistoryTable.test.tsx | 1 + .../settings/version-history/VersionHistoryTable.tsx | 1 + public/app/features/dashboard-scene/utils/interactions.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx index 63339065a30..0a15aff1dd5 100644 --- a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.test.tsx @@ -52,6 +52,7 @@ describe('VersionHistoryTable', () => { version: mockVersions[1].version, index: 1, confirm: false, + timestamp: mockVersions[1].created, }); }); }); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx index 3713f235106..48fc15963b8 100644 --- a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx @@ -70,6 +70,7 @@ export const VersionHistoryTable = ({ versions, canCompare, onCheck, onRestore } version: version.version, index: idx, confirm: false, + timestamp: version.created, }); }} > diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index ad1b3fbea0f..b52c09e190f 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -118,7 +118,7 @@ export const DashboardInteractions = { }, // Dashboards versions interactions - versionRestoreClicked: (properties: { version: number; index?: number; confirm: boolean }) => { + versionRestoreClicked: (properties: { version: number; index?: number; confirm: boolean; timestamp?: Date }) => { reportDashboardInteraction('version_restore_clicked', properties); }, showMoreVersionsClicked: () => { From 3cc4320aa9e209c191366d63199d38de5ffce451 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 12 Feb 2025 18:38:48 +0100 Subject: [PATCH 10/78] Alerting: Add rule conversion package (#100224) --- pkg/services/ngalert/models/alert_rule.go | 12 +- .../ngalert/models/alert_rule_test.go | 35 ++- pkg/services/ngalert/prom/convert.go | 220 ++++++++++++++++++ pkg/services/ngalert/prom/convert_test.go | 192 +++++++++++++++ pkg/services/ngalert/prom/models.go | 25 ++ pkg/services/ngalert/prom/models_test.go | 106 +++++++++ 6 files changed, 584 insertions(+), 6 deletions(-) create mode 100644 pkg/services/ngalert/prom/convert.go create mode 100644 pkg/services/ngalert/prom/convert_test.go create mode 100644 pkg/services/ngalert/prom/models.go create mode 100644 pkg/services/ngalert/prom/models_test.go diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index e11c5eb1add..66be69d0079 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -295,7 +295,8 @@ type AlertRule struct { } type AlertRuleMetadata struct { - EditorSettings EditorSettings `json:"editor_settings"` + EditorSettings EditorSettings `json:"editor_settings"` + PrometheusStyleRule *PrometheusStyleRule `json:"prometheus_style_rule,omitempty"` } type EditorSettings struct { @@ -303,6 +304,10 @@ type EditorSettings struct { SimplifiedNotificationsSection bool `json:"simplified_notifications_section"` } +type PrometheusStyleRule struct { + OriginalRuleDefinition string `json:"original_rule_definition,omitempty"` +} + // Namespaced describes a class of resources that are stored in a specific namespace. type Namespaced interface { GetNamespaceUID() string @@ -748,6 +753,11 @@ func (alertRule *AlertRule) Copy() *AlertRule { } } + if alertRule.Metadata.PrometheusStyleRule != nil { + prometheusStyleRule := *alertRule.Metadata.PrometheusStyleRule + result.Metadata.PrometheusStyleRule = &prometheusStyleRule + } + for _, s := range alertRule.NotificationSettings { result.NotificationSettings = append(result.NotificationSettings, CopyNotificationSettings(s)) } diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index 6594732c04b..9f6c8b52b96 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -841,7 +841,7 @@ func TestDiff(t *testing.T) { } }) - t.Run("should detect changes in Metadata", func(t *testing.T) { + t.Run("should detect changes in Metadata.EditorSettings", func(t *testing.T) { rule1 := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{EditorSettings: EditorSettings{ SimplifiedQueryAndExpressionsSection: false, SimplifiedNotificationsSection: false, @@ -858,6 +858,21 @@ func TestDiff(t *testing.T) { "Metadata.EditorSettings.SimplifiedNotificationsSection", }, diff.Paths()) }) + + t.Run("should detect changes in Metadata.PrometheusStyleRule", func(t *testing.T) { + rule1 := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{ + OriginalRuleDefinition: "data", + }})).GenerateRef() + + rule2 := CopyRule(rule1, RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{ + OriginalRuleDefinition: "updated data", + }})) + + diff := rule1.Diff(rule2) + assert.ElementsMatch(t, []string{ + "Metadata.PrometheusStyleRule.OriginalRuleDefinition", + }, diff.Paths()) + }) } func TestSortByGroupIndex(t *testing.T) { @@ -940,11 +955,21 @@ func TestAlertRuleGetKeyWithGroup(t *testing.T) { } func TestAlertRuleCopy(t *testing.T) { - for i := 0; i < 100; i++ { - rule := RuleGen.GenerateRef() + t.Run("should return a copy of the rule", func(t *testing.T) { + for i := 0; i < 100; i++ { + rule := RuleGen.GenerateRef() + copied := rule.Copy() + require.Empty(t, rule.Diff(copied)) + } + }) + + t.Run("should create a copy of the prometheus rule definition from the metadata", func(t *testing.T) { + rule := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{ + OriginalRuleDefinition: "data", + }})).GenerateRef() copied := rule.Copy() - require.Empty(t, rule.Diff(copied)) - } + require.NotSame(t, rule.Metadata.PrometheusStyleRule, copied.Metadata.PrometheusStyleRule) + }) } // This test makes sure the default generator diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go new file mode 100644 index 00000000000..d14a37aa9f5 --- /dev/null +++ b/pkg/services/ngalert/prom/convert.go @@ -0,0 +1,220 @@ +package prom + +import ( + "encoding/json" + "fmt" + "time" + + "gopkg.in/yaml.v3" + + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type Config struct { + DatasourceUID string + DatasourceType string + FromTimeRange *time.Duration + EvaluationOffset *time.Duration + ExecErrState models.ExecutionErrorState + NoDataState models.NoDataState + RecordingRules RulesConfig + AlertRules RulesConfig +} + +type RulesConfig struct { + IsPaused bool +} + +var ( + defaultTimeRange = 600 * time.Second + defaultEvaluationOffset = 0 * time.Minute + + defaultConfig = Config{ + FromTimeRange: &defaultTimeRange, + EvaluationOffset: &defaultEvaluationOffset, + ExecErrState: models.ErrorErrState, + NoDataState: models.NoData, + } +) + +type Converter struct { + cfg Config +} + +func NewConverter(cfg Config) (*Converter, error) { + if cfg.DatasourceUID == "" { + return nil, fmt.Errorf("datasource UID is required") + } + if cfg.DatasourceType == "" { + return nil, fmt.Errorf("datasource type is required") + } + if cfg.FromTimeRange == nil { + cfg.FromTimeRange = defaultConfig.FromTimeRange + } + if cfg.EvaluationOffset == nil { + cfg.EvaluationOffset = defaultConfig.EvaluationOffset + } + if cfg.ExecErrState == "" { + cfg.ExecErrState = defaultConfig.ExecErrState + } + if cfg.NoDataState == "" { + cfg.NoDataState = defaultConfig.NoDataState + } + + if cfg.DatasourceType != datasources.DS_PROMETHEUS && cfg.DatasourceType != datasources.DS_LOKI { + return nil, fmt.Errorf("invalid datasource type: %s", cfg.DatasourceType) + } + + return &Converter{ + cfg: cfg, + }, nil +} + +// PrometheusRulesToGrafana converts a Prometheus rule group into Grafana Alerting rule group. +func (p *Converter) PrometheusRulesToGrafana(orgID int64, namespaceUID string, group PrometheusRuleGroup) (*models.AlertRuleGroup, error) { + for _, rule := range group.Rules { + err := validatePrometheusRule(rule) + if err != nil { + return nil, fmt.Errorf("invalid Prometheus rule '%s': %w", rule.Alert, err) + } + } + + grafanaGroup, err := p.convertRuleGroup(orgID, namespaceUID, group) + if err != nil { + return nil, fmt.Errorf("failed to convert rule group '%s': %w", group.Name, err) + } + + return grafanaGroup, nil +} + +func validatePrometheusRule(rule PrometheusRule) error { + if rule.KeepFiringFor != nil { + return fmt.Errorf("keep_firing_for is not supported") + } + + return nil +} + +func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup PrometheusRuleGroup) (*models.AlertRuleGroup, error) { + uniqueNames := map[string]int{} + rules := make([]models.AlertRule, 0, len(promGroup.Rules)) + interval := time.Duration(promGroup.Interval) + for i, rule := range promGroup.Rules { + gr, err := p.convertRule(orgID, namespaceUID, promGroup.Name, rule) + if err != nil { + return nil, fmt.Errorf("failed to convert Prometheus rule '%s' to Grafana rule: %w", rule.Alert, err) + } + gr.RuleGroupIndex = i + 1 + gr.IntervalSeconds = int64(interval.Seconds()) + + // Check rule title uniqueness within the group. + uniqueNames[gr.Title]++ + if val := uniqueNames[gr.Title]; val > 1 { + gr.Title = fmt.Sprintf("%s (%d)", gr.Title, val) + } + + rules = append(rules, gr) + } + + result := &models.AlertRuleGroup{ + FolderUID: namespaceUID, + Interval: int64(interval.Seconds()), + Rules: rules, + Title: promGroup.Name, + } + + return result, nil +} + +func (p *Converter) convertRule(orgID int64, namespaceUID, group string, rule PrometheusRule) (models.AlertRule, error) { + var forInterval time.Duration + if rule.For != nil { + forInterval = time.Duration(*rule.For) + } + + queryNode, err := createAlertQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, rule.Expr, *p.cfg.FromTimeRange, *p.cfg.EvaluationOffset) + if err != nil { + return models.AlertRule{}, err + } + + var title string + if rule.Record != "" { + title = rule.Record + } else { + title = rule.Alert + } + + labels := make(map[string]string, len(rule.Labels)+1) + for k, v := range rule.Labels { + labels[k] = v + } + + originalRuleDefinition, err := yaml.Marshal(rule) + if err != nil { + return models.AlertRule{}, fmt.Errorf("failed to marshal original rule definition: %w", err) + } + + result := models.AlertRule{ + OrgID: orgID, + NamespaceUID: namespaceUID, + Title: title, + Data: []models.AlertQuery{queryNode}, + Condition: "A", + NoDataState: p.cfg.NoDataState, + ExecErrState: p.cfg.ExecErrState, + Annotations: rule.Annotations, + Labels: labels, + For: forInterval, + RuleGroup: group, + Metadata: models.AlertRuleMetadata{ + PrometheusStyleRule: &models.PrometheusStyleRule{ + OriginalRuleDefinition: string(originalRuleDefinition), + }, + }, + } + + if rule.Record != "" { + result.Record = &models.Record{ + From: "A", + Metric: rule.Record, + } + result.IsPaused = p.cfg.RecordingRules.IsPaused + } else { + result.IsPaused = p.cfg.AlertRules.IsPaused + } + + return result, nil +} + +func createAlertQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange, evaluationOffset time.Duration) (models.AlertQuery, error) { + modelData := map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": datasourceType, + "uid": datasourceUID, + }, + "expr": expr, + "instant": true, + "range": false, + "refId": "A", + } + + if datasourceType == datasources.DS_LOKI { + modelData["queryType"] = "instant" + } + + modelJSON, err := json.Marshal(modelData) + if err != nil { + return models.AlertQuery{}, err + } + + return models.AlertQuery{ + DatasourceUID: datasourceUID, + Model: modelJSON, + RefID: "A", + RelativeTimeRange: models.RelativeTimeRange{ + From: models.Duration(fromTimeRange + evaluationOffset), + To: models.Duration(0 + evaluationOffset), + }, + }, nil +} diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go new file mode 100644 index 00000000000..f175686fd3d --- /dev/null +++ b/pkg/services/ngalert/prom/convert_test.go @@ -0,0 +1,192 @@ +package prom + +import ( + "testing" + "time" + + prommodel "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +func TestPrometheusRulesToGrafana(t *testing.T) { + fiveMin := prommodel.Duration(5 * time.Minute) + + testCases := []struct { + name string + orgID int64 + namespace string + promGroup PrometheusRuleGroup + config Config + expectError bool + }{ + { + name: "valid rule group", + orgID: 1, + namespace: "some-namespace-uid", + promGroup: PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "cpu_usage > 80", + For: &fiveMin, + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "CPU usage is critical", + }, + }, + }, + }, + expectError: false, + }, + { + name: "rules with keep_firing_for are not supported", + orgID: 1, + namespace: "namespaceUID", + promGroup: PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "up == 0", + KeepFiringFor: &fiveMin, + }, + }, + }, + expectError: true, + }, + { + name: "rule with empty interval", + orgID: 1, + namespace: "namespaceUID", + promGroup: PrometheusRuleGroup{ + Name: "test-group-1", + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "up == 0", + }, + }, + }, + expectError: false, + }, + { + name: "recording rule", + orgID: 1, + namespace: "namespaceUID", + promGroup: PrometheusRuleGroup{ + Name: "test-group-1", + Rules: []PrometheusRule{ + { + Record: "some_metric", + Expr: "sum(rate(http_requests_total[5m]))", + }, + }, + }, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.config.DatasourceUID = "datasource-uid" + tc.config.DatasourceType = datasources.DS_PROMETHEUS + converter, err := NewConverter(tc.config) + require.NoError(t, err) + + grafanaGroup, err := converter.PrometheusRulesToGrafana(tc.orgID, tc.namespace, tc.promGroup) + + if tc.expectError { + require.Error(t, err, tc.name) + return + } + require.NoError(t, err, tc.name) + + require.Equal(t, tc.promGroup.Name, grafanaGroup.Title, tc.name) + expectedInterval := int64(time.Duration(tc.promGroup.Interval).Seconds()) + require.Equal(t, expectedInterval, grafanaGroup.Interval, tc.name) + + require.Equal(t, len(tc.promGroup.Rules), len(grafanaGroup.Rules), tc.name) + + for j, promRule := range tc.promGroup.Rules { + grafanaRule := grafanaGroup.Rules[j] + + if promRule.Record != "" { + require.Equal(t, promRule.Record, grafanaRule.Title) + } else { + require.Equal(t, promRule.Alert, grafanaRule.Title) + } + + var expectedFor time.Duration + if promRule.For != nil { + expectedFor = time.Duration(*promRule.For) + } + require.Equal(t, expectedFor, grafanaRule.For, tc.name) + + expectedLabels := make(map[string]string, len(promRule.Labels)+1) + for k, v := range promRule.Labels { + expectedLabels[k] = v + } + + require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name) + require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name) + require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To) + require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From) + + originalRuleDefinition, err := yaml.Marshal(promRule) + require.NoError(t, err) + require.Equal(t, string(originalRuleDefinition), grafanaRule.Metadata.PrometheusStyleRule.OriginalRuleDefinition) + } + }) + } +} + +func TestPrometheusRulesToGrafanaWithDuplicateRuleNames(t *testing.T) { + cfg := Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + } + converter, err := NewConverter(cfg) + require.NoError(t, err) + + promGroup := PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + Rules: []PrometheusRule{ + { + Alert: "alert", + Expr: "up", + }, + { + Alert: "alert", + Expr: "up", + }, + { + Alert: "another alert", + Expr: "up", + }, + { + Alert: "alert", + Expr: "up", + }, + }, + } + + group, err := converter.PrometheusRulesToGrafana(1, "namespaceUID", promGroup) + require.NoError(t, err) + + require.Equal(t, "test-group-1", group.Title) + require.Len(t, group.Rules, 4) + require.Equal(t, "alert", group.Rules[0].Title) + require.Equal(t, "alert (2)", group.Rules[1].Title) + require.Equal(t, "another alert", group.Rules[2].Title) + require.Equal(t, "alert (3)", group.Rules[3].Title) +} diff --git a/pkg/services/ngalert/prom/models.go b/pkg/services/ngalert/prom/models.go new file mode 100644 index 00000000000..f7e8bbfc95b --- /dev/null +++ b/pkg/services/ngalert/prom/models.go @@ -0,0 +1,25 @@ +package prom + +import ( + prommodel "github.com/prometheus/common/model" +) + +type PrometheusRulesFile struct { + Groups []PrometheusRuleGroup `yaml:"groups"` +} + +type PrometheusRuleGroup struct { + Name string `yaml:"name"` + Interval prommodel.Duration `yaml:"interval"` + Rules []PrometheusRule `yaml:"rules"` +} + +type PrometheusRule struct { + Alert string `yaml:"alert,omitempty"` + Expr string `yaml:"expr,omitempty"` + For *prommodel.Duration `yaml:"for,omitempty"` + KeepFiringFor *prommodel.Duration `yaml:"keep_firing_for,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty"` + Record string `yaml:"record,omitempty"` +} diff --git a/pkg/services/ngalert/prom/models_test.go b/pkg/services/ngalert/prom/models_test.go new file mode 100644 index 00000000000..fbf7d65a847 --- /dev/null +++ b/pkg/services/ngalert/prom/models_test.go @@ -0,0 +1,106 @@ +package prom + +import ( + "testing" + "time" + + prommodel "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestPrometheusRulesFileYAML(t *testing.T) { + interval := prommodel.Duration(5 * time.Minute) + alertFor := prommodel.Duration(10 * time.Minute) + keepFiring := prommodel.Duration(15 * time.Minute) + + tests := []struct { + name string + input PrometheusRulesFile + expectedYAML string + }{ + { + name: "simple alert rule and a recording rule", + input: PrometheusRulesFile{ + Groups: []PrometheusRuleGroup{ + { + Name: "test_group", + Interval: interval, + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "vector(0) > 90", + For: &alertFor, + KeepFiringFor: &keepFiring, + Labels: map[string]string{ + "team": "alerting", + }, + Annotations: map[string]string{ + "summary": "some summary", + "description": "some description", + }, + }, + { + Record: "vector(1)", + }, + }, + }, + }, + }, + expectedYAML: ` +groups: + - name: test_group + interval: 5m + rules: + - alert: alert-1 + expr: vector(0) > 90 + for: 10m + keep_firing_for: 15m + labels: + team: alerting + annotations: + description: some description + summary: some summary + - record: vector(1) +`, + }, + { + name: "empty rules file", + input: PrometheusRulesFile{ + Groups: []PrometheusRuleGroup{}, + }, + expectedYAML: `groups: []`, + }, + { + name: "empty group", + input: PrometheusRulesFile{ + Groups: []PrometheusRuleGroup{ + { + Name: "empty_group", + Interval: interval, + Rules: []PrometheusRule{}, + }, + }, + }, + expectedYAML: ` +groups: + - name: empty_group + interval: 5m + rules: []`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + yamlData, err := yaml.Marshal(tt.input) + require.NoError(t, err, "Failed to marshal to YAML") + require.YAMLEq(t, tt.expectedYAML, string(yamlData)) + + var parsed PrometheusRulesFile + err = yaml.Unmarshal(yamlData, &parsed) + require.NoError(t, err, "Failed to unmarshal from YAML") + + require.Equal(t, tt.input, parsed) + }) + } +} From c556f2062795f1f5ac4913a4996a079e9e4bbe82 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 12 Feb 2025 19:24:22 +0100 Subject: [PATCH 11/78] Alerting: Fix default max_attempts value in the docs (#100497) --- docs/sources/setup-grafana/configure-grafana/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 4805b42a1a7..e0b265a30d1 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1821,7 +1821,7 @@ The timeout string is a possibly signed sequence of decimal numbers, followed by #### `max_attempts` -Sets a maximum number of times Grafana attempts to evaluate an alert rule before giving up on that evaluation. The default value is `1`. +Sets a maximum number of times Grafana attempts to evaluate an alert rule before giving up on that evaluation. The default value is `3`. #### `min_interval` From 950726a3c5a3b151e61a2b5ddb7eab9e6adc39d0 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 12 Feb 2025 19:52:39 +0100 Subject: [PATCH 12/78] Dashboard V0->V1 Migration: Schema migration v38 (#99778) --- .../migration/schemaversion/migrations.go | 3 +- .../dashboard/migration/schemaversion/v38.go | 122 ++++++ .../migration/schemaversion/v38_test.go | 251 ++++++++++++ .../37.timeseries_table_display_mode.json | 360 +++++++++++++++++ .../37.timeseries_table_display_mode.38.json | 375 ++++++++++++++++++ .../37.timeseries_table_display_mode.39.json | 375 ++++++++++++++++++ .../37.timeseries_table_display_mode.40.json | 375 ++++++++++++++++++ .../38.transform_timeseries_table.38.json | 153 +++++++ 8 files changed, 2013 insertions(+), 1 deletion(-) create mode 100644 pkg/apis/dashboard/migration/schemaversion/v38.go create mode 100644 pkg/apis/dashboard/migration/schemaversion/v38_test.go create mode 100644 pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations.go b/pkg/apis/dashboard/migration/schemaversion/migrations.go index f689894cf34..5875261aaeb 100644 --- a/pkg/apis/dashboard/migration/schemaversion/migrations.go +++ b/pkg/apis/dashboard/migration/schemaversion/migrations.go @@ -5,11 +5,12 @@ import "strconv" type SchemaVersionMigrationFunc func(map[string]interface{}) error const ( - MINIUM_VERSION = 38 + MINIUM_VERSION = 37 LATEST_VERSION = 40 ) var Migrations = map[int]SchemaVersionMigrationFunc{ + 38: V38, 39: V39, 40: V40, } diff --git a/pkg/apis/dashboard/migration/schemaversion/v38.go b/pkg/apis/dashboard/migration/schemaversion/v38.go new file mode 100644 index 00000000000..a750ac4223f --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v38.go @@ -0,0 +1,122 @@ +package schemaversion + +// V38 updates the configuration of the table panel to use the new cellOptions format +// and updates the overrides to use the new cellOptions format +func V38(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = int(38) + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, panel := range panels { + p, ok := panel.(map[string]interface{}) + if !ok { + continue + } + + // Only process table panels + if p["type"] != "table" { + continue + } + + fieldConfig, ok := p["fieldConfig"].(map[string]interface{}) + if !ok { + continue + } + + defaults, ok := fieldConfig["defaults"].(map[string]interface{}) + if !ok { + continue + } + + custom, ok := defaults["custom"].(map[string]interface{}) + if !ok { + continue + } + + // Migrate displayMode to cellOptions + if displayMode, exists := custom["displayMode"]; exists { + if displayModeStr, ok := displayMode.(string); ok { + custom["cellOptions"] = migrateTableDisplayModeToCellOptions(displayModeStr) + } + // Delete the legacy field + delete(custom, "displayMode") + } + + // Update any overrides referencing the cell display mode + migrateOverrides(fieldConfig) + } + + return nil +} + +// migrateOverrides updates the overrides configuration to use the new cellOptions format +func migrateOverrides(fieldConfig map[string]interface{}) { + overrides, ok := fieldConfig["overrides"].([]interface{}) + if !ok { + return + } + + for _, override := range overrides { + o, ok := override.(map[string]interface{}) + if !ok { + continue + } + + properties, ok := o["properties"].([]interface{}) + if !ok { + continue + } + + for _, property := range properties { + prop, ok := property.(map[string]interface{}) + if !ok { + continue + } + + // Update the id to cellOptions + if prop["id"] == "custom.displayMode" { + prop["id"] = "custom.cellOptions" + if value, ok := prop["value"]; ok { + if valueStr, ok := value.(string); ok { + prop["value"] = migrateTableDisplayModeToCellOptions(valueStr) + } + } + } + } + } +} + +// migrateTableDisplayModeToCellOptions converts the old displayMode string to the new cellOptions format +func migrateTableDisplayModeToCellOptions(displayMode string) map[string]interface{} { + switch displayMode { + case "basic", "gradient-gauge", "lcd-gauge": + gaugeMode := "basic" + if displayMode == "gradient-gauge" { + gaugeMode = "gradient" + } else if displayMode == "lcd-gauge" { + gaugeMode = "lcd" + } + return map[string]interface{}{ + "type": "gauge", + "mode": gaugeMode, + } + + case "color-background", "color-background-solid": + mode := "basic" + if displayMode == "color-background" { + mode = "gradient" + } + return map[string]interface{}{ + "type": "color-background", + "mode": mode, + } + + default: + return map[string]interface{}{ + "type": displayMode, + } + } +} diff --git a/pkg/apis/dashboard/migration/schemaversion/v38_test.go b/pkg/apis/dashboard/migration/schemaversion/v38_test.go new file mode 100644 index 00000000000..f4bbb8c9cb6 --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v38_test.go @@ -0,0 +1,251 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion" +) + +func TestV38(t *testing.T) { + tests := []migrationTestCase{ + { + name: "no table panels", + input: map[string]interface{}{ + "schemaVersion": 37, + "title": "Test Dashboard", + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel 1", + }, + }, + }, + expected: map[string]interface{}{ + "title": "Test Dashboard", + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel 1", + }, + }, + }, + }, + { + name: "table panel with basic gauge displayMode", + input: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "displayMode": "basic", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "cellOptions": map[string]interface{}{ + "type": "gauge", + "mode": "basic", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "table panel with gradient-gauge displayMode", + input: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "displayMode": "gradient-gauge", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "cellOptions": map[string]interface{}{ + "type": "gauge", + "mode": "gradient", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "table panel with lcd-gauge displayMode", + input: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "displayMode": "lcd-gauge", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "cellOptions": map[string]interface{}{ + "type": "gauge", + "mode": "lcd", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "table panel with color-background displayMode", + input: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "displayMode": "color-background", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "cellOptions": map[string]interface{}{ + "type": "color-background", + "mode": "gradient", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "table panel with color-background-solid displayMode", + input: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "displayMode": "color-background-solid", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "cellOptions": map[string]interface{}{ + "type": "color-background", + "mode": "basic", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "table panel with default displayMode", + input: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "displayMode": "some-other-mode", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 38, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "cellOptions": map[string]interface{}{ + "type": "some-other-mode", + }, + }, + }, + }, + }, + }, + }, + }, + } + runMigrationTests(t, tests, schemaversion.V38) +} diff --git a/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json b/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json new file mode 100644 index 00000000000..e800450fd51 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json @@ -0,0 +1,360 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "basic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Basic Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "gradient-gauge" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Gradient Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "lcd-gauge" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "LCD Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "color-background" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "color-background-solid" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Solid Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "some-other-mode" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Other Display Mode", + "type": "table" + } + + + ], + "preload": false, + "refresh": true, + "schemaVersion": 37, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json new file mode 100644 index 00000000000..30f64b0bc8e --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json @@ -0,0 +1,375 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "basic", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Basic Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "gradient", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Gradient Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "lcd", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "LCD Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "gradient", + "type": "color-background" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "basic", + "type": "color-background" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Solid Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "type": "some-other-mode" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Other Display Mode", + "type": "table" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json new file mode 100644 index 00000000000..1173963dc58 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json @@ -0,0 +1,375 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "basic", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Basic Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "gradient", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Gradient Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "lcd", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "LCD Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "gradient", + "type": "color-background" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "basic", + "type": "color-background" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Solid Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "type": "some-other-mode" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Other Display Mode", + "type": "table" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json new file mode 100644 index 00000000000..843cc6284f7 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json @@ -0,0 +1,375 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "basic", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Basic Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "gradient", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Gradient Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "lcd", + "type": "gauge" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "LCD Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "gradient", + "type": "color-background" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "mode": "basic", + "type": "color-background" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Solid Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "cellOptions": { + "type": "some-other-mode" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Other Display Mode", + "type": "table" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 40, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json new file mode 100644 index 00000000000..081cb14634f --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json @@ -0,0 +1,153 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "B" + } + ], + "title": "Panel Title", + "transformations": [ + { + "id": "timeSeriesTable", + "options": { + "refIdToStat": { + "A": "mean", + "B": "max" + } + } + } + ], + "type": "timeseries" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file From e60e217a235cddaf90d5229eea85f8bcb6c4c814 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 12 Feb 2025 20:19:57 +0100 Subject: [PATCH 13/78] Dashboard V0->V1 Migration: Schema migration v37 (#99962) --- .../migration/schemaversion/migrations.go | 3 +- .../dashboard/migration/schemaversion/v37.go | 62 +++ .../migration/schemaversion/v37_test.go | 170 +++++++++ .../input/36.legend_normalization.json | 123 ++++++ .../output/36.legend_normalization.37.json | 132 +++++++ .../output/36.legend_normalization.38.json | 132 +++++++ .../output/36.legend_normalization.39.json | 132 +++++++ .../output/36.legend_normalization.40.json | 132 +++++++ .../37.timeseries_table_display_mode.37.json | 358 ++++++++++++++++++ 9 files changed, 1243 insertions(+), 1 deletion(-) create mode 100644 pkg/apis/dashboard/migration/schemaversion/v37.go create mode 100644 pkg/apis/dashboard/migration/schemaversion/v37_test.go create mode 100644 pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations.go b/pkg/apis/dashboard/migration/schemaversion/migrations.go index 5875261aaeb..6d3955c329f 100644 --- a/pkg/apis/dashboard/migration/schemaversion/migrations.go +++ b/pkg/apis/dashboard/migration/schemaversion/migrations.go @@ -5,11 +5,12 @@ import "strconv" type SchemaVersionMigrationFunc func(map[string]interface{}) error const ( - MINIUM_VERSION = 37 + MINIUM_VERSION = 36 LATEST_VERSION = 40 ) var Migrations = map[int]SchemaVersionMigrationFunc{ + 37: V37, 38: V38, 39: V39, 40: V40, diff --git a/pkg/apis/dashboard/migration/schemaversion/v37.go b/pkg/apis/dashboard/migration/schemaversion/v37.go new file mode 100644 index 00000000000..c040daf842c --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v37.go @@ -0,0 +1,62 @@ +package schemaversion + +// V37 normalizes legend configuration in panels to use a consistent format: +// - Converts boolean legend values to object format +// - Standardizes hidden legends to use showLegend: false with displayMode: list +// - Ensures visible legends have showLegend: true +func V37(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = int(37) + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, panel := range panels { + p, ok := panel.(map[string]interface{}) + if !ok { + continue + } + + options, ok := p["options"].(map[string]interface{}) + if !ok { + continue + } + + // Skip if no legend config exists + legendValue := options["legend"] + if legendValue == nil { + continue + } + + // Convert boolean legend to object format + if legendBool, ok := legendValue.(bool); ok { + options["legend"] = map[string]interface{}{ + "displayMode": "list", + "showLegend": legendBool, + } + continue + } + + // Handle object format legend + legend, ok := legendValue.(map[string]interface{}) + if !ok { + continue + } + + displayMode, hasDisplayMode := legend["displayMode"].(string) + showLegend, hasShowLegend := legend["showLegend"].(bool) + + // Normalize hidden legends + if (hasDisplayMode && displayMode == "hidden") || (hasShowLegend && !showLegend) { + legend["displayMode"] = "list" + legend["showLegend"] = false + continue + } + + // Ensure visible legends have showLegend true + legend["showLegend"] = true + } + + return nil +} diff --git a/pkg/apis/dashboard/migration/schemaversion/v37_test.go b/pkg/apis/dashboard/migration/schemaversion/v37_test.go new file mode 100644 index 00000000000..e0b2f4c27cb --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v37_test.go @@ -0,0 +1,170 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion" +) + +func TestV37(t *testing.T) { + tests := []migrationTestCase{ + { + name: "no legend config", + input: map[string]interface{}{ + "schemaVersion": 36, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "options": map[string]interface{}{}, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "options": map[string]interface{}{}, + }, + }, + }, + }, + { + name: "boolean legend true", + input: map[string]interface{}{ + "schemaVersion": 36, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": true, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "list", + "showLegend": true, + }, + }, + }, + }, + }, + }, + { + name: "boolean legend false", + input: map[string]interface{}{ + "schemaVersion": 36, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": false, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "list", + "showLegend": false, + }, + }, + }, + }, + }, + }, + { + name: "hidden displayMode", + input: map[string]interface{}{ + "schemaVersion": 36, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "hidden", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "list", + "showLegend": false, + }, + }, + }, + }, + }, + }, + { + name: "showLegend false", + input: map[string]interface{}{ + "schemaVersion": 36, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "showLegend": false, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "list", + "showLegend": false, + }, + }, + }, + }, + }, + }, + { + name: "visible legend", + input: map[string]interface{}{ + "schemaVersion": 36, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "table", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 37, + "panels": []interface{}{ + map[string]interface{}{ + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "displayMode": "table", + "showLegend": true, + }, + }, + }, + }, + }, + }, + } + runMigrationTests(t, tests, schemaversion.V37) +} diff --git a/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json b/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json new file mode 100644 index 00000000000..93ddfecb078 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json @@ -0,0 +1,123 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "type": "graph", + "options": {}, + "title": "No Legend Config", + "id": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + } + }, + { + "options": { + "legend": true + }, + "title": "Boolean Legend True", + "id": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + } + }, + { + "options": { + "legend": false + }, + "title": "Boolean Legend False", + "id": 3, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + } + }, + { + "options": { + "legend": { + "displayMode": "hidden" + } + }, + "title": "Hidden DisplayMode", + "id": 4, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + } + }, + { + "options": { + "legend": { + "showLegend": false + } + }, + "title": "ShowLegend False", + "id": 5, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + } + }, + { + "options": { + "legend": { + "displayMode": "table" + } + }, + "title": "Visible Legend", + "id": 6, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + } + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 36, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json new file mode 100644 index 00000000000..1e6e0484aad --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json @@ -0,0 +1,132 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "title": "No Legend Config", + "type": "graph" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "showLegend": true + } + }, + "title": "Boolean Legend True" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Boolean Legend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Hidden DisplayMode" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "ShowLegend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "table", + "showLegend": true + } + }, + "title": "Visible Legend" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 37, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json new file mode 100644 index 00000000000..14c5c32071f --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json @@ -0,0 +1,132 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "title": "No Legend Config", + "type": "graph" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "showLegend": true + } + }, + "title": "Boolean Legend True" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Boolean Legend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Hidden DisplayMode" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "ShowLegend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "table", + "showLegend": true + } + }, + "title": "Visible Legend" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json new file mode 100644 index 00000000000..5e3239e89fa --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json @@ -0,0 +1,132 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "title": "No Legend Config", + "type": "graph" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "showLegend": true + } + }, + "title": "Boolean Legend True" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Boolean Legend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Hidden DisplayMode" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "ShowLegend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "table", + "showLegend": true + } + }, + "title": "Visible Legend" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json new file mode 100644 index 00000000000..91d625264b0 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json @@ -0,0 +1,132 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "title": "No Legend Config", + "type": "graph" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "showLegend": true + } + }, + "title": "Boolean Legend True" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Boolean Legend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Hidden DisplayMode" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "ShowLegend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "table", + "showLegend": true + } + }, + "title": "Visible Legend" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 40, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json new file mode 100644 index 00000000000..4b7c2fa4572 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json @@ -0,0 +1,358 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "basic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Basic Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "gradient-gauge" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Gradient Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "lcd-gauge" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "LCD Gauge Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "color-background" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "color-background-solid" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Color Background Solid Display Mode", + "type": "table" + }, + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "displayMode": "some-other-mode" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "showHeader": true + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "Other Display Mode", + "type": "table" + } + ], + "preload": false, + "refresh": true, + "schemaVersion": 37, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file From d1dee968c38791ed8fc4e1ae71229064b0e5bd96 Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Wed, 12 Feb 2025 19:23:09 +0000 Subject: [PATCH 14/78] Schema V2: Adjust quick_ranges in time settings and remove time_options (#100135) * adjut quickRanges type in v2 * clean up unused time_options property * remove deprecated time_options property on time picker * add schema migration for time_options * adjust test --- .betterer.results | 3 +- devenv/bulk-dashboards/bulkdash.jsonnet | 12 -------- .../panel_tests_graph.json | 3 +- .../panel_tests_graph_time_regions.json | 11 ------- .../panel_tests_polystat.json | 3 +- .../influxdb-templated.json | 1 - .../datasource-mssql/mssql_fakedata.json | 3 +- .../datasource-mssql/mssql_unittest.json | 3 +- .../datasource-mysql/mysql_fakedata.json | 3 +- .../datasource-mysql/mysql_unittest.json | 3 +- .../postgres_fakedata.json | 3 +- .../postgres_unittest.json | 3 +- .../datasource-testdata/demo1.json | 1 - .../new_features_in_v62.json | 3 +- devenv/dev-dashboards/home.json | 11 ------- .../panel-bargauge/bar_gauge_demo.json | 3 +- .../panel-bargauge/panel_tests_bar_gauge.json | 11 ------- .../panel_tests_bar_gauge2.json | 3 +- .../panel-common/lazy_loading.json | 3 +- .../panel-common/panels_without_title.json | 3 +- .../panel-gauge/gauge-multi-series.json | 3 +- .../panel-gauge/gauge_tests.json | 3 +- .../graph-gradient-area-fills.json | 3 +- .../panel-graph/graph-time-regions.json | 3 +- .../panel-graph/graph_tests.json | 3 +- .../panel-polystat/polystat_test.json | 3 +- .../panel-table/table_tests.json | 3 +- .../timeseries-gradient-area.json | 3 +- .../slow_queries_and_annotations.json | 3 +- .../scenarios/time_zone_support.json | 3 +- .../dashboards/alerts/overview.json | 11 ------- .../dashboards/mysql/overview.json | 11 ------- .../dashboards/alerts/overview.json | 11 ------- .../dashboards/mysql/overview.json | 11 ------- .../view-dashboard-json-model/index.md | 4 +-- kinds/dashboard/dashboard_kind.cue | 4 +-- .../src/dashboards/grafana_stats.json | 3 +- .../src/dashboards/prometheus_2_stats.json | 3 +- .../src/dashboards/prometheus_stats.json | 3 +- .../raw/dashboard/x/dashboard_types.gen.ts | 7 +---- .../dashboard/v2alpha0/dashboard.schema.cue | 8 ++++- .../src/schema/dashboard/v2alpha0/examples.ts | 1 - .../schema/dashboard/v2alpha0/types.gen.ts | 27 ++++++++--------- pkg/kinds/dashboard/dashboard_spec_gen.go | 5 +--- .../service/testdata/dashboard.json | 3 +- .../containing-id/dashboard1.json | 11 ------- .../dashboard-with-uid/dashboard1.json | 11 ------- .../folder-one/dashboard1.json | 11 ------- .../folder-one/dashboard2.json | 11 ------- .../folderOne/dashboard1.json | 11 ------- .../folderTwo/dashboard2.json | 11 ------- .../folders-from-files-structure/root.json | 11 ------- .../one-dashboard/dashboard1.json | 11 ------- .../two-dashboards-with-uid/dashboard1.json | 11 ------- .../two-dashboards-with-uid/dashboard2.json | 11 ------- .../unprovision/dashboard1.json | 11 ------- pkg/tests/api/dashboards/home.json | 11 ------- .../DashboardScenePageStateManager.test.ts | 1 - .../DashboardSceneSerializer.test.ts | 2 -- .../serialization/DashboardSceneSerializer.ts | 3 +- .../transformSceneToSaveModel.test.ts.snap | 6 ++-- ...sformSceneToSaveModelSchemaV2.test.ts.snap | 1 - .../transformSceneToSaveModel.test.ts | 1 - .../transformSceneToSaveModelSchemaV2.ts | 7 +++-- .../__mocks__/dashboardHistoryMocks.ts | 1 - .../api/ResponseTransformers.test.ts | 29 ++++++++++++++++--- .../dashboard/api/ResponseTransformers.ts | 4 +-- .../GeneralSettings.test.tsx | 1 - .../containers/PublicDashboardPage.test.tsx | 2 +- .../dashboard/state/DashboardMigrator.test.ts | 22 ++++++++++++++ .../dashboard/state/DashboardMigrator.ts | 12 ++++++-- .../dashboards/streaming.json | 3 +- .../graphite/dashboards/carbon_metrics.json | 1 - .../graphite/dashboards/metrictank.json | 1 - .../prometheus/dashboards/grafana_stats.json | 3 +- .../dashboards/prometheus_2_stats.json | 3 +- .../dashboards/prometheus_stats.json | 3 +- public/dashboards/default.json | 1 - public/dashboards/home.json | 1 - public/dashboards/template_vars.json | 1 - scripts/import_many_dashboards.sh | 2 +- 81 files changed, 129 insertions(+), 345 deletions(-) diff --git a/.betterer.results b/.betterer.results index 9e1de13c1be..ca4b89c9605 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2212,8 +2212,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], "public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] diff --git a/devenv/bulk-dashboards/bulkdash.jsonnet b/devenv/bulk-dashboards/bulkdash.jsonnet index 1a77d8abd70..05e396df2d6 100644 --- a/devenv/bulk-dashboards/bulkdash.jsonnet +++ b/devenv/bulk-dashboards/bulkdash.jsonnet @@ -1118,18 +1118,6 @@ "1d" ], "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "2h", - " 6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "type": "timepicker" }, "timezone": "browser", diff --git a/devenv/dev-dashboards-without-uid/panel_tests_graph.json b/devenv/dev-dashboards-without-uid/panel_tests_graph.json index b5d50f4f7b7..6bba1d9ae02 100644 --- a/devenv/dev-dashboards-without-uid/panel_tests_graph.json +++ b/devenv/dev-dashboards-without-uid/panel_tests_graph.json @@ -1639,8 +1639,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Panel Tests - Graph", diff --git a/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json b/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json index 3ff76d12df2..98d49958aaf 100644 --- a/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json +++ b/devenv/dev-dashboards-without-uid/panel_tests_graph_time_regions.json @@ -490,17 +490,6 @@ "1h", "2h", "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" ] }, "timezone": "browser", diff --git a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json index 951bb780017..25b1f7154e5 100644 --- a/devenv/dev-dashboards-without-uid/panel_tests_polystat.json +++ b/devenv/dev-dashboards-without-uid/panel_tests_polystat.json @@ -3408,8 +3408,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Polystat", diff --git a/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json b/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json index 97719e82251..f46ccc2042f 100644 --- a/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json +++ b/devenv/dev-dashboards/datasource-influxdb/influxdb-templated.json @@ -312,7 +312,6 @@ "now": true, "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], "status": "Stable", - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], "type": "timepicker" }, "timezone": "browser", diff --git a/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json b/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json index 19c5e1d0718..8fda3e8e714 100644 --- a/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json +++ b/devenv/dev-dashboards/datasource-mssql/mssql_fakedata.json @@ -537,8 +537,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Datasource tests - MSSQL", diff --git a/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json b/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json index 0137001067c..40f9d55e5ca 100644 --- a/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json +++ b/devenv/dev-dashboards/datasource-mssql/mssql_unittest.json @@ -2831,8 +2831,7 @@ "to": "2018-03-15T13:55:01.000Z" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Datasource tests - MSSQL (unit test)", diff --git a/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json b/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json index 96e4688f7bd..cd1a24fb5bd 100644 --- a/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json +++ b/devenv/dev-dashboards/datasource-mysql/mysql_fakedata.json @@ -541,8 +541,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Datasource tests - MySQL", diff --git a/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json b/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json index b68c9db97ec..2f2a42c1175 100644 --- a/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json +++ b/devenv/dev-dashboards/datasource-mysql/mysql_unittest.json @@ -2643,8 +2643,7 @@ "to": "2018-03-15T13:55:01.000Z" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Datasource tests - MySQL (unittest)", diff --git a/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json b/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json index d7d9514e639..750b3284517 100644 --- a/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json +++ b/devenv/dev-dashboards/datasource-postgres/postgres_fakedata.json @@ -577,8 +577,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Datasource tests - Postgres", diff --git a/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json b/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json index a114ed1b7ef..acec0d08a44 100644 --- a/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json +++ b/devenv/dev-dashboards/datasource-postgres/postgres_unittest.json @@ -2621,8 +2621,7 @@ "to": "2018-03-15T13:55:01.000Z" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Datasource tests - Postgres (unittest)", diff --git a/devenv/dev-dashboards/datasource-testdata/demo1.json b/devenv/dev-dashboards/datasource-testdata/demo1.json index abe39ffeb55..6d8034f81e6 100644 --- a/devenv/dev-dashboards/datasource-testdata/demo1.json +++ b/devenv/dev-dashboards/datasource-testdata/demo1.json @@ -1092,7 +1092,6 @@ "now": true, "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], "status": "Stable", - "time_options": ["5m", "15m", "1h", "2h", " 6h", "12h", "24h", "2d", "7d", "30d"], "type": "timepicker" }, "timezone": "browser", diff --git a/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json b/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json index 4b9535ecbbd..a002a208f85 100644 --- a/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json +++ b/devenv/dev-dashboards/datasource-testdata/new_features_in_v62.json @@ -1326,8 +1326,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "New Features in v6.2", diff --git a/devenv/dev-dashboards/home.json b/devenv/dev-dashboards/home.json index 9c3d65d4add..840d32919ea 100644 --- a/devenv/dev-dashboards/home.json +++ b/devenv/dev-dashboards/home.json @@ -240,17 +240,6 @@ "1h", "2h", "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" ] }, "timezone": "", diff --git a/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json b/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json index a708467d7be..eda6ccfe996 100644 --- a/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json +++ b/devenv/dev-dashboards/panel-bargauge/bar_gauge_demo.json @@ -654,8 +654,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["2s", "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["2s", "5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Bar Gauge Demo", diff --git a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json index 29a6929cf8f..3dfa360c740 100644 --- a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json +++ b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json @@ -1423,17 +1423,6 @@ "1h", "2h", "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" ] }, "timezone": "", diff --git a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json index 0f36c203cd0..06fc26e382d 100644 --- a/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json +++ b/devenv/dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json @@ -519,8 +519,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Bar Gauge 2", diff --git a/devenv/dev-dashboards/panel-common/lazy_loading.json b/devenv/dev-dashboards/panel-common/lazy_loading.json index 859eede4b4f..960c466124c 100644 --- a/devenv/dev-dashboards/panel-common/lazy_loading.json +++ b/devenv/dev-dashboards/panel-common/lazy_loading.json @@ -2202,8 +2202,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Lazy Loading", diff --git a/devenv/dev-dashboards/panel-common/panels_without_title.json b/devenv/dev-dashboards/panel-common/panels_without_title.json index 44bd210e73b..8f62c9cca5e 100644 --- a/devenv/dev-dashboards/panel-common/panels_without_title.json +++ b/devenv/dev-dashboards/panel-common/panels_without_title.json @@ -893,8 +893,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - With & Without title", diff --git a/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json b/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json index 09b72e5c030..f7ff80edc12 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json +++ b/devenv/dev-dashboards/panel-gauge/gauge-multi-series.json @@ -254,8 +254,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Gauge Multi Series", diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests.json b/devenv/dev-dashboards/panel-gauge/gauge_tests.json index 458f53dbc08..309a255fcc7 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests.json @@ -1319,8 +1319,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Gauge", diff --git a/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json b/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json index 01e9e8c2f43..c2d27efd469 100644 --- a/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json +++ b/devenv/dev-dashboards/panel-graph/graph-gradient-area-fills.json @@ -372,8 +372,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Graph - Gradient Area Fills", diff --git a/devenv/dev-dashboards/panel-graph/graph-time-regions.json b/devenv/dev-dashboards/panel-graph/graph-time-regions.json index 2031788ae3f..a4e03148536 100644 --- a/devenv/dev-dashboards/panel-graph/graph-time-regions.json +++ b/devenv/dev-dashboards/panel-graph/graph-time-regions.json @@ -569,8 +569,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Panel Tests - Graph Time Regions", diff --git a/devenv/dev-dashboards/panel-graph/graph_tests.json b/devenv/dev-dashboards/panel-graph/graph_tests.json index 4d01c2cb534..bd1fc95d3d6 100644 --- a/devenv/dev-dashboards/panel-graph/graph_tests.json +++ b/devenv/dev-dashboards/panel-graph/graph_tests.json @@ -1639,8 +1639,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Panel Tests - Graph", diff --git a/devenv/dev-dashboards/panel-polystat/polystat_test.json b/devenv/dev-dashboards/panel-polystat/polystat_test.json index 6be355ebd99..faa84463019 100644 --- a/devenv/dev-dashboards/panel-polystat/polystat_test.json +++ b/devenv/dev-dashboards/panel-polystat/polystat_test.json @@ -3408,8 +3408,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Polystat", diff --git a/devenv/dev-dashboards/panel-table/table_tests.json b/devenv/dev-dashboards/panel-table/table_tests.json index 8582ef068d7..b8ca436e110 100644 --- a/devenv/dev-dashboards/panel-table/table_tests.json +++ b/devenv/dev-dashboards/panel-table/table_tests.json @@ -440,8 +440,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Panel Tests - Table", diff --git a/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json b/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json index a7389cc6ed6..8f3267a62d7 100644 --- a/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json +++ b/devenv/dev-dashboards/panel-timeseries/timeseries-gradient-area.json @@ -562,8 +562,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel Tests - Graph NG - Gradient Area Fills", diff --git a/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json b/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json index a7cc41acd8f..8966137b55e 100644 --- a/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json +++ b/devenv/dev-dashboards/scenarios/slow_queries_and_annotations.json @@ -1132,8 +1132,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Panel tests - Slow Queries & Annotations", diff --git a/devenv/dev-dashboards/scenarios/time_zone_support.json b/devenv/dev-dashboards/scenarios/time_zone_support.json index feb317f917e..cc1f81ee221 100644 --- a/devenv/dev-dashboards/scenarios/time_zone_support.json +++ b/devenv/dev-dashboards/scenarios/time_zone_support.json @@ -684,8 +684,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "utc", "title": "Panel Tests - Time zone support", diff --git a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json index b4946cfb6b3..0357c3d10f1 100644 --- a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json +++ b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/alerts/overview.json @@ -151,17 +151,6 @@ "1h", "2h", "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" ] }, "timezone": "", diff --git a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json index 7643250ec28..2bf789366cc 100644 --- a/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json +++ b/devenv/docker/ha-test-unified-alerting/grafana/provisioning/dashboards/mysql/overview.json @@ -5376,17 +5376,6 @@ "1d" ], "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "type": "timepicker" }, "timezone": "browser", diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json index b4946cfb6b3..0357c3d10f1 100644 --- a/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json +++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/alerts/overview.json @@ -151,17 +151,6 @@ "1h", "2h", "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" ] }, "timezone": "", diff --git a/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json index 7643250ec28..2bf789366cc 100644 --- a/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json +++ b/devenv/docker/ha_test/grafana/provisioning/dashboards/mysql/overview.json @@ -5376,17 +5376,6 @@ "1d" ], "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "type": "timepicker" }, "timezone": "browser", diff --git a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md index 44b20434a0d..0fff2d65d9f 100644 --- a/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md +++ b/docs/sources/dashboards/build-dashboards/view-dashboard-json-model/index.md @@ -138,12 +138,12 @@ The grid has a negative gravity that moves panels up if there is empty space abo "nowDelay": "", "quick_ranges": [ { - "display": "Last 6 hours" + "display": "Last 6 hours", "from": "now-6h", "to": "now" }, { - "display": "Last 7 days" + "display": "Last 7 days", "from": "now-7d", "to": "now" } diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index da8be7ef3d5..bbf3cb24321 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -74,7 +74,7 @@ lineage: schemas: [{ // Version of the JSON schema, incremented each time a Grafana update brings // changes to said schema. - schemaVersion: uint16 | *39 + schemaVersion: uint16 | *41 // Version of the dashboard, incremented each time the dashboard is updated. version?: uint32 @@ -473,8 +473,6 @@ lineage: schemas: [{ hidden?: bool | *false // Interval options available in the refresh picker dropdown. refresh_intervals?: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] - // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - time_options?: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] // Quick ranges for time picker. quick_ranges?: [...#TimeOption] // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. diff --git a/packages/grafana-prometheus/src/dashboards/grafana_stats.json b/packages/grafana-prometheus/src/dashboards/grafana_stats.json index 0131aa9bc40..292f93394f3 100644 --- a/packages/grafana-prometheus/src/dashboards/grafana_stats.json +++ b/packages/grafana-prometheus/src/dashboards/grafana_stats.json @@ -1178,8 +1178,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Grafana metrics", diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json index 034663fe6f4..5a6fbdf8518 100644 --- a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json +++ b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json @@ -1394,8 +1394,7 @@ }, "timepicker": { "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Prometheus 2.0 Stats", diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json index 8a2764c5cb7..42ea6e7a4d5 100644 --- a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json +++ b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json @@ -825,8 +825,7 @@ }, "timepicker": { "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Prometheus Stats", 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 e200106086a..fbaa9c8f09b 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 @@ -681,17 +681,12 @@ export interface TimePickerConfig { * Interval options available in the refresh picker dropdown. */ refresh_intervals?: Array; - /** - * Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - */ - time_options?: Array; } export const defaultTimePickerConfig: Partial = { hidden: false, quick_ranges: [], refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], - time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], }; /** @@ -1205,7 +1200,7 @@ export const defaultDashboard: Partial = { graphTooltip: DashboardCursorSync.Off, links: [], panels: [], - schemaVersion: 39, + schemaVersion: 41, tags: [], timezone: 'browser', }; 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 4b983ddf7ae..21012f3461a 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -447,6 +447,12 @@ QueryGroupKind: { spec: QueryGroupSpec } +TimeRangeOption: { + display: string | *"Last 6 hours" + from: string | *"now-6h" + to: string | *"now" +} + // Time configuration // It defines the default time config for the time picker, the refresh picker for the specific dashboard. TimeSettingsSpec: { @@ -463,7 +469,7 @@ TimeSettingsSpec: { // Interval options available in the refresh picker dropdown. autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - quickRanges: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] // v1: timepicker.time_options , not exposed in the UI + quickRanges?: [...TimeRangeOption] // v1: timepicker.quick_ranges , not exposed in the UI // Whether timepicker is visible or not. hideTimepicker: bool // v1: timepicker.hidden // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday". diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts index 40b091f29dd..c12f1cc73f7 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/examples.ts @@ -15,7 +15,6 @@ export const handyTestingSchema: DashboardV2Spec = { from: 'now-1h', hideTimepicker: false, nowDelay: '1m', - quickRanges: [], timezone: 'UTC', to: 'now', weekStart: 'monday', diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index 83c733c6f60..e85e71996fd 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -620,6 +620,18 @@ export const defaultQueryGroupKind = (): QueryGroupKind => ({ spec: defaultQueryGroupSpec(), }); +export interface TimeRangeOption { + display: string; + from: string; + to: string; +} + +export const defaultTimeRangeOption = (): TimeRangeOption => ({ + display: "Last 6 hours", + from: "now-6h", + to: "now", +}); + // Time configuration // It defines the default time config for the time picker, the refresh picker for the specific dashboard. export interface TimeSettingsSpec { @@ -638,8 +650,8 @@ export interface TimeSettingsSpec { // v1: timepicker.refresh_intervals autoRefreshIntervals: string[]; // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - // v1: timepicker.time_options , not exposed in the UI - quickRanges: string[]; + // v1: timepicker.quick_ranges , not exposed in the UI + quickRanges?: TimeRangeOption[]; // Whether timepicker is visible or not. // v1: timepicker.hidden hideTimepicker: boolean; @@ -668,17 +680,6 @@ export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({ "1h", "2h", "1d", -], - quickRanges: [ -"5m", -"15m", -"1h", -"6h", -"12h", -"24h", -"2d", -"7d", -"30d", ], hideTimepicker: false, fiscalYearStartMonth: 0, diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 38e17ae6219..526c5a66056 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -48,8 +48,6 @@ type TimePickerConfig struct { Hidden *bool `json:"hidden,omitempty"` // Interval options available in the refresh picker dropdown. RefreshIntervals []string `json:"refresh_intervals,omitempty"` - // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - TimeOptions []string `json:"time_options,omitempty"` // Quick ranges for time picker. QuickRanges []TimeOption `json:"quick_ranges,omitempty"` // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values. @@ -61,7 +59,6 @@ func NewTimePickerConfig() *TimePickerConfig { return &TimePickerConfig{ Hidden: (func(input bool) *bool { return &input })(false), RefreshIntervals: []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"}, - TimeOptions: []string{"5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"}, } } @@ -927,7 +924,7 @@ func NewSpec() *Spec { Editable: (func(input bool) *bool { return &input })(true), GraphTooltip: (func(input DashboardCursorSync) *DashboardCursorSync { return &input })(DashboardCursorSyncOff), FiscalYearStartMonth: (func(input uint8) *uint8 { return &input })(0), - SchemaVersion: 39, + SchemaVersion: 41, } } diff --git a/pkg/services/dashboardimport/service/testdata/dashboard.json b/pkg/services/dashboardimport/service/testdata/dashboard.json index 401d2e2676a..9358c6b40fb 100644 --- a/pkg/services/dashboardimport/service/testdata/dashboard.json +++ b/pkg/services/dashboardimport/service/testdata/dashboard.json @@ -209,8 +209,7 @@ }, "timepicker": { "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Prometheus 2.0 Stats", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json index 94b5c9a1c02..668b7ba4f1b 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/containing-id/dashboard1.json @@ -30,17 +30,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json index c0ab4838bf3..c69015426a6 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/dashboard-with-uid/dashboard1.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json index 8c8cf42fc78..b45c8dece35 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard1.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json index 94d29339a13..aa15ce8a12d 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folder-one/dashboard2.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json index 8c8cf42fc78..b45c8dece35 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderOne/dashboard1.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json index 94d29339a13..aa15ce8a12d 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/folderTwo/dashboard2.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json index 6743fb1f6a6..4948686435d 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/folders-from-files-structure/root.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json index 9f786032f0e..3fd0d5aa927 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/one-dashboard/dashboard1.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json index c0ab4838bf3..c69015426a6 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard1.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json index c0ab4838bf3..c69015426a6 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/two-dashboards-with-uid/dashboard2.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json b/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json index 8c8cf42fc78..b45c8dece35 100644 --- a/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json +++ b/pkg/services/provisioning/dashboards/testdata/test-dashboards/unprovision/dashboard1.json @@ -134,17 +134,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ], "refresh_intervals": [ "5s", "10s", diff --git a/pkg/tests/api/dashboards/home.json b/pkg/tests/api/dashboards/home.json index ee516adac16..08e991b5925 100644 --- a/pkg/tests/api/dashboards/home.json +++ b/pkg/tests/api/dashboards/home.json @@ -210,17 +210,6 @@ "1h", "2h", "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" ] }, "timezone": "", diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 2e46767d485..a70fa61ab05 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -661,7 +661,6 @@ const customHomeDashboardV2Spec = { to: 'now', autoRefresh: '', autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], - quickRanges: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], hideTimepicker: false, fiscalYearStartMonth: 0, }, diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 8f09578ae54..747c69eaf07 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -584,7 +584,6 @@ describe('DashboardSceneSerializer', () => { to: '', autoRefresh: '', autoRefreshIntervals: [], - quickRanges: [], hideTimepicker: false, fiscalYearStartMonth: 0, timezone: '', @@ -646,7 +645,6 @@ describe('DashboardSceneSerializer', () => { from: 'now-1h', hideTimepicker: false, nowDelay: undefined, - quickRanges: [], timezone: 'browser', to: 'now', }); diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index 039f366de9a..442b8356f91 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -4,6 +4,7 @@ import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alp import { AnnoKeyDashboardSnapshotOriginalUrl } from 'app/features/apiserver/types'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; +import { DASHBOARD_SCHEMA_VERSION } from 'app/features/dashboard/state/DashboardMigrator'; import { getPanelPluginCounts, getV1SchemaVariables, @@ -185,7 +186,7 @@ export class V2DashboardSerializer if (this.initialSaveModel) { return { - schemaVersion: 40, + schemaVersion: DASHBOARD_SCHEMA_VERSION, uid: s.state.uid, title: this.initialSaveModel.title, panels_count: panelPluginIds.length || 0, diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index 8ab27fc7a70..8146499446c 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -280,7 +280,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back ], "preload": false, "refresh": "", - "schemaVersion": 40, + "schemaVersion": 41, "tags": [ "templating", "gdev", @@ -548,7 +548,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho ], "preload": false, "refresh": "5m", - "schemaVersion": 40, + "schemaVersion": 41, "tags": [ "tag1", "tag2", @@ -906,7 +906,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr ], "preload": false, "refresh": "", - "schemaVersion": 40, + "schemaVersion": 41, "tags": [ "gdev", "graph-ng", diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index 7695bba0ab6..41482314925 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -195,7 +195,6 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "from": "now-1h", "hideTimepicker": false, "nowDelay": "1m", - "quickRanges": [], "timezone": "UTC", "to": "now", "weekStart": "monday", diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index fbe3b3c4c0d..d9576c47fd0 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -185,7 +185,6 @@ describe('transformSceneToSaveModel', () => { timepicker: { ...dashboard_to_load1.timepicker, refresh_intervals: ['5m', '15m', '30m', '1h'], - time_options: ['5m', '15m', '30m'], hidden: true, }, links: [{ ...NEW_LINK, title: 'Link 1' }], diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index bd8ed9f3fac..979fba6483c 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -89,7 +89,6 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps to: timeRange.to, autoRefresh: refreshPicker?.state.refresh || '', autoRefreshIntervals: refreshPicker?.state.intervals, - quickRanges: [], //FIXME is coming timepicker.time_options, hideTimepicker: controlsState?.hideTimeControls ?? false, weekStart: timeRange.weekStart, fiscalYearStartMonth: timeRange.fiscalYearStartMonth, @@ -502,7 +501,11 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec { if (!('autoRefreshIntervals' in dash.timeSettings) || !Array.isArray(dash.timeSettings.autoRefreshIntervals)) { throw new Error('AutoRefreshIntervals is not an array'); } - if (!('quickRanges' in dash.timeSettings) || !Array.isArray(dash.timeSettings.quickRanges)) { + if ( + 'quickRanges' in dash.timeSettings && + dash.timeSettings.quickRanges && + !Array.isArray(dash.timeSettings.quickRanges) + ) { throw new Error('QuickRanges is not an array'); } if (!('hideTimepicker' in dash.timeSettings) || typeof dash.timeSettings.hideTimepicker !== 'boolean') { diff --git a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts index 3fbefb31c92..0b6de1db51a 100644 --- a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts +++ b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts @@ -166,7 +166,6 @@ export function restore(version: number, restoredFrom?: number) { }, timepicker: { refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], - time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], }, timezone: 'utc', title: 'History Dashboard', diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index 63cb8dc1e26..c598db36084 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -113,8 +113,19 @@ describe('ResponseTransformers', () => { timepicker: { refresh_intervals: ['5s', '10s', '30s'], hidden: false, - time_options: ['5m', '15m', '1h'], nowDelay: '1m', + quick_ranges: [ + { + display: 'Last 6 hours', + from: 'now-6h', + to: 'now', + }, + { + display: 'Last 7 days', + from: 'now-7d', + to: 'now', + }, + ], }, fiscalYearStartMonth: 1, weekStart: 'monday', @@ -462,7 +473,7 @@ describe('ResponseTransformers', () => { expect(spec.timeSettings.autoRefresh).toBe(dashboardV1.refresh); expect(spec.timeSettings.autoRefreshIntervals).toEqual(dashboardV1.timepicker?.refresh_intervals); expect(spec.timeSettings.hideTimepicker).toBe(dashboardV1.timepicker?.hidden); - expect(spec.timeSettings.quickRanges).toEqual(dashboardV1.timepicker?.time_options); + expect(spec.timeSettings.quickRanges).toEqual(dashboardV1.timepicker?.quick_ranges); expect(spec.timeSettings.nowDelay).toBe(dashboardV1.timepicker?.nowDelay); expect(spec.timeSettings.fiscalYearStartMonth).toBe(dashboardV1.fiscalYearStartMonth); expect(spec.timeSettings.weekStart).toBe(dashboardV1.weekStart); @@ -655,7 +666,18 @@ describe('ResponseTransformers', () => { autoRefresh: '5m', autoRefreshIntervals: ['5s', '10s', '30s'], hideTimepicker: false, - quickRanges: ['5m', '15m', '1h'], + quickRanges: [ + { + display: 'Last 6 hours', + from: 'now-6h', + to: 'now', + }, + { + display: 'Last 7 days', + from: 'now-7d', + to: 'now', + }, + ], nowDelay: '1m', fiscalYearStartMonth: 1, weekStart: 'monday', @@ -730,7 +752,6 @@ describe('ResponseTransformers', () => { expect(dashboard.refresh).toBe(dashboardV2.spec.timeSettings.autoRefresh); expect(dashboard.timepicker?.refresh_intervals).toEqual(dashboardV2.spec.timeSettings.autoRefreshIntervals); expect(dashboard.timepicker?.hidden).toBe(dashboardV2.spec.timeSettings.hideTimepicker); - expect(dashboard.timepicker?.time_options).toEqual(dashboardV2.spec.timeSettings.quickRanges); expect(dashboard.timepicker?.nowDelay).toBe(dashboardV2.spec.timeSettings.nowDelay); expect(dashboard.fiscalYearStartMonth).toBe(dashboardV2.spec.timeSettings.fiscalYearStartMonth); expect(dashboard.weekStart).toBe(dashboardV2.spec.timeSettings.weekStart); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index b4841435f3a..69edee427e4 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -160,7 +160,7 @@ export function ensureV2Response( autoRefreshIntervals: dashboard.timepicker?.refresh_intervals || timeSettingsDefaults.autoRefreshIntervals, fiscalYearStartMonth: dashboard.fiscalYearStartMonth || timeSettingsDefaults.fiscalYearStartMonth, hideTimepicker: dashboard.timepicker?.hidden || timeSettingsDefaults.hideTimepicker, - quickRanges: dashboard.timepicker?.time_options || timeSettingsDefaults.quickRanges, + quickRanges: dashboard.timepicker?.quick_ranges, // casting WeekStart here to avoid editing old schema weekStart: (dashboard.weekStart as WeekStart) || timeSettingsDefaults.weekStart, nowDelay: dashboard.timepicker?.nowDelay || timeSettingsDefaults.nowDelay, @@ -252,7 +252,7 @@ export function ensureV1Response( timepicker: { refresh_intervals: spec.timeSettings.autoRefreshIntervals, hidden: spec.timeSettings.hideTimepicker, - time_options: spec.timeSettings.quickRanges, + quick_ranges: spec.timeSettings.quickRanges, nowDelay: spec.timeSettings.nowDelay, }, fiscalYearStartMonth: spec.timeSettings.fiscalYearStartMonth, diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx index edd31d452a5..0983707cf94 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx @@ -23,7 +23,6 @@ const setupTestContext = (options: Partial) => { description: 'test dashboard description', timepicker: { refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'], - time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], hidden: false, }, timezone: 'utc', diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx index 915dd50d69a..b7e9e3ecf2e 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx @@ -245,7 +245,7 @@ describe('PublicDashboardPage', () => { ...dashboardBase, getModel: () => getTestDashboard({ - timepicker: { hidden: false, refresh_intervals: [], time_options: [] }, + timepicker: { hidden: false, refresh_intervals: [] }, }), }, }); diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index 6ba62a860e5..c41b87c0e4b 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2428,6 +2428,28 @@ describe('when migrating variable refresh to on dashboard load', () => { }); }); +describe('when migrating time_options in timepicker', () => { + let model: DashboardModel; + + it('should remove the property', () => { + model = new DashboardModel({ + timepicker: { + //@ts-expect-error + time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], + }, + }); + + expect(model.timepicker).not.toHaveProperty('time_options'); + }); + + it('should not throw with empty timepicker', () => { + //@ts-expect-error + model = new DashboardModel({}); + + expect(model.timepicker).not.toHaveProperty('time_options'); + }); +}); + function createRow(options: any, panelDescriptions: any[]) { const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN; const { collapse, showTitle, title, repeat, repeatIteration } = options; diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index 02efa8b8ff0..7cfa50f0b7d 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -81,7 +81,7 @@ type PanelSchemeUpgradeHandler = (panel: PanelModel) => PanelModel; * kinds/dashboard/dashboard_kind.cue * Example PR: #87712 */ -export const DASHBOARD_SCHEMA_VERSION = 40; +export const DASHBOARD_SCHEMA_VERSION = 41; export class DashboardMigrator { dashboard: DashboardModel; @@ -905,12 +905,20 @@ export class DashboardMigrator { } if (oldVersion < 40) { - // In old ashboards refresh property can be a boolean + // In old dashboards refresh property can be a boolean if (typeof this.dashboard.refresh !== 'string') { this.dashboard.refresh = ''; } } + if (oldVersion < 41) { + // time_options is a legacy property that was not used since grafana version 5 + // therefore deprecating this property from the schema + if ('time_options' in this.dashboard.timepicker) { + delete this.dashboard.timepicker.time_options; + } + } + /** * -==- Add migration here -==- * Your migration should go below the previous diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json b/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json index 8498d79f2f1..b5bd877bcaa 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/dashboards/streaming.json @@ -199,8 +199,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Simple Streaming Example", diff --git a/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json b/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json index 94e8e685d45..418ba46835f 100644 --- a/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json +++ b/public/app/plugins/datasource/graphite/dashboards/carbon_metrics.json @@ -154,7 +154,6 @@ "now": true, "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], "status": "Stable", - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], "type": "timepicker" }, "templating": { diff --git a/public/app/plugins/datasource/graphite/dashboards/metrictank.json b/public/app/plugins/datasource/graphite/dashboards/metrictank.json index 18b2e3939ff..b70ad4d58ec 100644 --- a/public/app/plugins/datasource/graphite/dashboards/metrictank.json +++ b/public/app/plugins/datasource/graphite/dashboards/metrictank.json @@ -4771,7 +4771,6 @@ "now": true, "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], "status": "Stable", - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], "type": "timepicker" }, "timezone": "utc", diff --git a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json index a121de7fe5a..d465f31c1f5 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json @@ -1177,8 +1177,7 @@ "to": "now" }, "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "", "title": "Grafana metrics", diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json index 3d4cea64f05..57e2bb5e47d 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json @@ -1393,8 +1393,7 @@ }, "timepicker": { "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Prometheus 2.0 Stats", diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json index 9169006b895..383f75c9011 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json @@ -824,8 +824,7 @@ }, "timepicker": { "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] }, "timezone": "browser", "title": "Prometheus Stats", diff --git a/public/dashboards/default.json b/public/dashboards/default.json index c59f98ec1dd..2b2ef5d1c9b 100644 --- a/public/dashboards/default.json +++ b/public/dashboards/default.json @@ -131,7 +131,6 @@ "collapse": false, "enable": true, "status": "Stable", - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], "now": true } diff --git a/public/dashboards/home.json b/public/dashboards/home.json index 718b6b52079..8d5cfd00e52 100644 --- a/public/dashboards/home.json +++ b/public/dashboards/home.json @@ -68,7 +68,6 @@ "timepicker": { "hidden": true, "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], "type": "timepicker" }, "timezone": "browser", diff --git a/public/dashboards/template_vars.json b/public/dashboards/template_vars.json index 33478bc8081..04cb1c95d79 100644 --- a/public/dashboards/template_vars.json +++ b/public/dashboards/template_vars.json @@ -169,7 +169,6 @@ "notice": false, "enable": true, "status": "Stable", - "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"], "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], "now": true } diff --git a/scripts/import_many_dashboards.sh b/scripts/import_many_dashboards.sh index 60d4fcd55b3..47cbb2c36c2 100755 --- a/scripts/import_many_dashboards.sh +++ b/scripts/import_many_dashboards.sh @@ -3,6 +3,6 @@ for index in {0..3000} do echo -n "index $index" - curl 'http://localhost:3000/api/dashboards/import' -H 'Pragma: no-cache' -H 'Origin: http://localhost:3000' -H 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: en-US,en;q=0.8,sv;q=0.6' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.101 Safari/537.36' -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/json, text/plain, */*' -H 'Cache-Control: no-cache' -H 'Referer: http://localhost:3000/dashboard/new?editview=import' -H 'Cookie: grafana_sess=662a67f11b47e657; grafana_user=admin; grafana_remember=bd839923f24f648c7cb53ede6ff9ef40826204e9a22df8f9; toggles=%7B%7D' -H 'Connection: keep-alive' --data-binary $'{"dashboard":{"__inputs":[{"name":"DS_GRAPHITE","label":"graphite","description":"","type":"datasource","pluginId":"graphite","pluginName":"Graphite"}],"__requires":[{"type":"panel","id":"singlestat","name":"Singlestat","version":""},{"type":"panel","id":"graph","name":"Graph","version":""},{"type":"grafana","id":"grafana","name":"Grafana","version":"3.1.0"},{"type":"datasource","id":"graphite","name":"Graphite","version":"1.0.0"}],"id":null,"title":"Big Dashboard dashname '"$index"$'","tags":["startpage","home","presentation"],"style":"dark","timezone":"browser","editable":true,"hideControls":false,"sharedCrosshair":true,"rows":[{"collapse":false,"editable":true,"height":"100px","panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":16,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_02.counters.requests.count"}],"thresholds":"100,270","title":"Sign ups","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":15,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"100,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":17,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_04.counters.requests.count"}],"thresholds":"100,270","title":"Sign outs","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":18,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_03.counters.requests.count, 0.3)"}],"thresholds":"100,270","title":"Support calls","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapse":false,"editable":true,"height":218.4375,"panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":20,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"200,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":24,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"bytes","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":22,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.4)"}],"thresholds":"200,270","title":"Memory","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":21,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":26,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":25,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapsable":true,"collapse":false,"editable":true,"height":"250px","notice":false,"panels":[{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":4,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true},{"aliasColors":{"logins":"#7EB26D","logins (-1 day)":"#447EBC"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":3,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":true,"max":true,"min":true,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":1,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), \'logins\')"},{"refId":"B","target":"alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), \'1h\'), 2), \'logins (-1 hour)\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"logins","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":19,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true}],"title":"test"},{"collapsable":true,"collapse":false,"editable":true,"height":"300px","notice":false,"panels":[{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":2,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"upper_25":"#F9E2D2","upper_50":"#F2C96D","upper_75":"#EAB839"},"annotate":{"enable":false},"bars":true,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":5,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":false,"max":false,"min":false,"rightSide":true,"show":true,"total":false,"values":true},"legend_counts":true,"lines":false,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, \'4min\', \'avg\'), 4)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"client side full page load","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":14,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true}],"title":""},{"collapsable":true,"collapse":false,"editable":true,"height":"200px","notice":false,"panels":[{"aliasColors":{"cpu1":"#EF843C","cpu2":"#EAB839","upper_25":"#B7DBAB","upper_50":"#7EB26D","upper_75":"#629E51","upper_90":"#629E51","upper_95":"#508642"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":null,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":6,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":true,"legendSideLastValue":true,"max":false,"min":false,"rightSide":true,"show":false,"total":false,"values":true},"legend_counts":true,"lines":true,"linewidth":2,"links":[],"nullPointMode":"connected","options":false,"percentage":false,"pointradius":1,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"this is test of breaking","yaxis":1}],"span":12,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(statsd.fakesite.timers.ads_timer.*,4)"},{"refId":"B","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_95,-1),\'cpu1\')"},{"refId":"C","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_75,-1),\'cpu2\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"transparent":true,"type":"graph","xaxis":{"show":false},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":false},{"format":"short","logBase":1,"max":null,"min":null,"show":false}],"zerofill":true}],"title":"test"}],"time":{"from":"now-30m","to":"now"},"timepicker":{"collapse":false,"enable":true,"notice":false,"now":true,"refresh_intervals":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"status":"Stable","time_options":["5m","15m","1h","2h"," 6h","12h","24h","2d","7d","30d"],"type":"timepicker"},"templating":{"enable":false,"list":[]},"annotations":{"enable":false,"list":[]},"refresh":false,"schemaVersion":12,"version":5,"links":[],"gnetId":null},"overwrite":true,"inputs":[{"name":"DS_GRAPHITE","type":"datasource","pluginId":"graphite","value":"graphite"}]}' --compressed + curl 'http://localhost:3000/api/dashboards/import' -H 'Pragma: no-cache' -H 'Origin: http://localhost:3000' -H 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: en-US,en;q=0.8,sv;q=0.6' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.101 Safari/537.36' -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/json, text/plain, */*' -H 'Cache-Control: no-cache' -H 'Referer: http://localhost:3000/dashboard/new?editview=import' -H 'Cookie: grafana_sess=662a67f11b47e657; grafana_user=admin; grafana_remember=bd839923f24f648c7cb53ede6ff9ef40826204e9a22df8f9; toggles=%7B%7D' -H 'Connection: keep-alive' --data-binary $'{"dashboard":{"__inputs":[{"name":"DS_GRAPHITE","label":"graphite","description":"","type":"datasource","pluginId":"graphite","pluginName":"Graphite"}],"__requires":[{"type":"panel","id":"singlestat","name":"Singlestat","version":""},{"type":"panel","id":"graph","name":"Graph","version":""},{"type":"grafana","id":"grafana","name":"Grafana","version":"3.1.0"},{"type":"datasource","id":"graphite","name":"Graphite","version":"1.0.0"}],"id":null,"title":"Big Dashboard dashname '"$index"$'","tags":["startpage","home","presentation"],"style":"dark","timezone":"browser","editable":true,"hideControls":false,"sharedCrosshair":true,"rows":[{"collapse":false,"editable":true,"height":"100px","panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":16,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_02.counters.requests.count"}],"thresholds":"100,270","title":"Sign ups","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":15,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"100,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":17,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"apps.backend.backend_04.counters.requests.count"}],"thresholds":"100,270","title":"Sign outs","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(245, 54, 54, 0.9)","rgba(237, 129, 40, 0.89)","rgba(50, 172, 45, 0.97)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":100,"minValue":0,"show":false,"thresholdLabels":false,"thresholdMarkers":true},"id":18,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":3,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":true},"targets":[{"refId":"A","target":"scale(apps.backend.backend_03.counters.requests.count, 0.3)"}],"thresholds":"100,270","title":"Support calls","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapse":false,"editable":true,"height":218.4375,"panels":[{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":20,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.7)"}],"thresholds":"200,270","title":"Logins","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":24,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"bytes","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":22,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.4)"}],"thresholds":"200,270","title":"Memory","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":21,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":26,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.2)"}],"thresholds":"200,270","title":"Google hits","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1},{"cacheTimeout":null,"colorBackground":false,"colorValue":true,"colors":["rgba(50, 172, 45, 0.97)","rgba(237, 129, 40, 0.89)","rgba(245, 54, 54, 0.9)"],"datasource":"${DS_GRAPHITE}","editable":true,"error":false,"format":"none","gauge":{"maxValue":300,"minValue":0,"show":true,"thresholdLabels":false,"thresholdMarkers":true},"id":25,"interval":null,"links":[],"maxDataPoints":100,"nullPointMode":"connected","nullText":null,"postfix":"","postfixFontSize":"50%","prefix":"","prefixFontSize":"50%","span":2,"sparkline":{"fillColor":"rgba(31, 118, 189, 0.18)","full":true,"lineColor":"rgb(31, 120, 193)","show":false},"targets":[{"refId":"A","target":"scale(apps.backend.backend_01.counters.requests.count, 0.8)"}],"thresholds":"200,270","title":"Logouts","type":"singlestat","valueFontSize":"100%","valueMaps":[{"op":"=","text":"N/A","value":"null"}],"valueName":"avg","mappingTypes":[{"name":"value to text","value":1},{"name":"range to text","value":2}],"rangeMaps":[{"from":"null","to":"null","text":"N/A"}],"mappingType":1}],"title":"New row"},{"collapsable":true,"collapse":false,"editable":true,"height":"250px","notice":false,"panels":[{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":4,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true},{"aliasColors":{"logins":"#7EB26D","logins (-1 day)":"#447EBC"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":3,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":true,"max":true,"min":true,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":1,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), \'logins\')"},{"refId":"B","target":"alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), \'1h\'), 2), \'logins (-1 hour)\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"logins","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"cpu":"#E24D42","memory":"#6ED0E0","statsd.fakesite.counters.session_start.desktop.count":"#6ED0E0"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":19,"interactive":true,"legend":{"avg":false,"current":true,"max":false,"min":true,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"cpu","fill":0,"lines":true,"yaxis":2,"zindex":2},{"alias":"memory","pointradius":2,"points":true}],"span":4,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"hide":false,"refId":"A","target":"alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), \'cpu\')"},{"refId":"B","target":"alias(statsd.fakesite.counters.session_start.desktop.count, \'memory\')"}],"timeFrom":null,"timeShift":"1h","timezone":"browser","title":"Memory / CPU","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"bytes","logBase":1,"max":null,"min":null,"show":true},{"format":"percent","logBase":1,"max":null,"min":0,"show":true}],"zerofill":true}],"title":"test"},{"collapsable":true,"collapse":false,"editable":true,"height":"300px","notice":false,"panels":[{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":2,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"upper_25":"#F9E2D2","upper_50":"#F2C96D","upper_75":"#EAB839"},"annotate":{"enable":false},"bars":true,"datasource":"${DS_GRAPHITE}","editable":true,"fill":1,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":5,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":false,"max":false,"min":false,"rightSide":true,"show":true,"total":false,"values":true},"legend_counts":true,"lines":false,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, \'4min\', \'avg\'), 4)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"client side full page load","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true},{"aliasColors":{"web_server_01":"#B7DBAB","web_server_02":"#7EB26D","web_server_03":"#508642","web_server_04":"#3F6833"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":8,"grid":{"max":null,"min":0,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":14,"interactive":true,"legend":{"alignAsTable":false,"avg":false,"current":false,"max":false,"min":false,"rightSide":false,"show":true,"total":false,"values":false},"legend_counts":true,"lines":true,"linewidth":2,"nullPointMode":"connected","options":false,"percentage":false,"pointradius":5,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[],"span":4,"spyable":true,"stack":true,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"server requests","tooltip":{"msResolution":false,"query_as_alias":true,"shared":true,"value_type":"cumulative","sort":0},"type":"graph","xaxis":{"show":true},"yaxes":[{"format":"short","logBase":1,"max":null,"min":null,"show":true},{"format":"short","logBase":1,"max":null,"min":null,"show":true}],"zerofill":true}],"title":""},{"collapsable":true,"collapse":false,"editable":true,"height":"200px","notice":false,"panels":[{"aliasColors":{"cpu1":"#EF843C","cpu2":"#EAB839","upper_25":"#B7DBAB","upper_50":"#7EB26D","upper_75":"#629E51","upper_90":"#629E51","upper_95":"#508642"},"annotate":{"enable":false},"bars":false,"datasource":"${DS_GRAPHITE}","editable":true,"fill":3,"grid":{"max":null,"min":null,"threshold1":null,"threshold1Color":"rgba(216, 200, 27, 0.27)","threshold2":null,"threshold2Color":"rgba(234, 112, 112, 0.22)"},"id":6,"interactive":true,"legend":{"alignAsTable":true,"avg":true,"current":true,"legendSideLastValue":true,"max":false,"min":false,"rightSide":true,"show":false,"total":false,"values":true},"legend_counts":true,"lines":true,"linewidth":2,"links":[],"nullPointMode":"connected","options":false,"percentage":false,"pointradius":1,"points":false,"renderer":"flot","resolution":100,"scale":1,"seriesOverrides":[{"alias":"this is test of breaking","yaxis":1}],"span":12,"spyable":true,"stack":false,"steppedLine":false,"targets":[{"refId":"A","target":"aliasByNode(statsd.fakesite.timers.ads_timer.*,4)"},{"refId":"B","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_95,-1),\'cpu1\')"},{"refId":"C","target":"alias(scale(statsd.fakesite.timers.ads_timer.upper_75,-1),\'cpu2\')"}],"timeFrom":null,"timeShift":null,"timezone":"browser","title":"","tooltip":{"msResolution":false,"query_as_alias":true,"shared":false,"value_type":"cumulative","sort":0},"transparent":true,"type":"graph","xaxis":{"show":false},"yaxes":[{"format":"ms","logBase":1,"max":null,"min":null,"show":false},{"format":"short","logBase":1,"max":null,"min":null,"show":false}],"zerofill":true}],"title":"test"}],"time":{"from":"now-30m","to":"now"},"timepicker":{"collapse":false,"enable":true,"notice":false,"now":true,"refresh_intervals":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"status":"Stable","type":"timepicker"},"templating":{"enable":false,"list":[]},"annotations":{"enable":false,"list":[]},"refresh":false,"schemaVersion":12,"version":5,"links":[],"gnetId":null},"overwrite":true,"inputs":[{"name":"DS_GRAPHITE","type":"datasource","pluginId":"graphite","value":"graphite"}]}' --compressed done From 62e06cfac8096d7547d6e3cc7a28c6bcfdc626c8 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:53:32 -0600 Subject: [PATCH 15/78] Actions: Fix support in `StateTimeline` and `XYChart` (#100543) Co-authored-by: Leon Sorokin --- .../plugins/panel/barchart/BarChartPanel.tsx | 4 +-- .../panel/candlestick/CandlestickPanel.tsx | 4 +-- .../state-timeline/StateTimelinePanel.tsx | 4 +-- .../state-timeline/StateTimelineTooltip2.tsx | 12 +++++--- .../status-history/StatusHistoryPanel.tsx | 4 +-- .../panel/timeseries/TimeSeriesPanel.tsx | 4 +-- public/app/plugins/panel/trend/TrendPanel.tsx | 4 +-- .../plugins/panel/xychart/XYChartPanel.tsx | 9 +++++- .../plugins/panel/xychart/XYChartTooltip.tsx | 29 ++++++++++++++----- 9 files changed, 49 insertions(+), 25 deletions(-) diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index dba432c57d0..424bbd16de0 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -157,8 +157,8 @@ export const BarChartPanel = (props: PanelProps) => { hoverMode={ options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll } - getDataLinks={(seriesIdx: number, dataIdx: number) => - vizSeries[0].fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + getDataLinks={(seriesIdx, dataIdx) => + vizSeries[0].fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { return ( diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index 4ed97d59456..16eeeae3ca6 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -282,8 +282,8 @@ export const CandlestickPanel = ({ clientZoom={true} syncMode={cursorSync} syncScope={eventsScope} - getDataLinks={(seriesIdx: number, dataIdx: number) => - alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + getDataLinks={(seriesIdx, dataIdx) => + alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index 9524beba351..7a23e4a6898 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -107,8 +107,8 @@ export const StateTimelinePanel = ({ queryZoom={onChangeTimeRange} syncMode={cursorSync} syncScope={eventsScope} - getDataLinks={(seriesIdx: number, dataIdx: number) => - alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + getDataLinks={(seriesIdx, dataIdx) => + alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx index a2f2665a11d..b400b9e6e73 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx @@ -68,12 +68,16 @@ export const StateTimelineTooltip2 = ({ let footer: ReactNode; - if (isPinned && seriesIdx != null) { + if (seriesIdx != null) { const field = series.fields[seriesIdx]; - const dataIdx = dataIdxs[seriesIdx]!; - const actions = getFieldActions(series, field, replaceVariables!, dataIdx); + const hasOneClickLink = dataLinks.some((dataLink) => dataLink.oneClick === true); - footer = ; + if (isPinned || hasOneClickLink) { + const dataIdx = dataIdxs[seriesIdx]!; + const actions = getFieldActions(series, field, replaceVariables!, dataIdx); + + footer = ; + } } const headerItem: VizTooltipItem = { diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 8ed1a266a27..1f410d1578c 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -119,8 +119,8 @@ export const StatusHistoryPanel = ({ queryZoom={onChangeTimeRange} syncMode={cursorSync} syncScope={eventsScope} - getDataLinks={(seriesIdx: number, dataIdx: number) => - alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + getDataLinks={(seriesIdx, dataIdx) => + alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index c83af4b2703..f8866b705f3 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -106,8 +106,8 @@ export const TimeSeriesPanel = ({ clientZoom={true} syncMode={cursorSync} syncScope={eventsScope} - getDataLinks={(seriesIdx: number, dataIdx: number) => - alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + getDataLinks={(seriesIdx, dataIdx) => + alignedFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange2, viaSync, dataLinks) => { if (enableAnnotationCreation && timeRange2 != null) { diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index 67dcf81bb4e..1fe5ced2a49 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -119,8 +119,8 @@ export const TrendPanel = ({ hoverMode={ options.tooltip.mode === TooltipDisplayMode.Single ? TooltipHoverMode.xOne : TooltipHoverMode.xAll } - getDataLinks={(seriesIdx: number, dataIdx: number) => - alignedDataFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + getDataLinks={(seriesIdx, dataIdx) => + alignedDataFrame.fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } render={(u, dataIdxs, seriesIdx, isPinned = false, dismiss, timeRange, viaSync, dataLinks) => { return ( diff --git a/public/app/plugins/panel/xychart/XYChartPanel.tsx b/public/app/plugins/panel/xychart/XYChartPanel.tsx index f22fd8248e1..25ef452dc6b 100644 --- a/public/app/plugins/panel/xychart/XYChartPanel.tsx +++ b/public/app/plugins/panel/xychart/XYChartPanel.tsx @@ -17,6 +17,8 @@ import { import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils'; +import { getDataLinks } from '../status-history/utils'; + import { XYChartTooltip } from './XYChartTooltip'; import { Options } from './panelcfg.gen'; import { prepConfig } from './scatter'; @@ -113,7 +115,11 @@ export const XYChartPanel2 = (props: Props2) => { { + getDataLinks={(seriesIdx, dataIdx) => { + const xySeries = series[seriesIdx - 1]; + return getDataLinks(xySeries.y.field, dataIdx); + }} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { return ( { isPinned={isPinned} seriesIdx={seriesIdx!} replaceVariables={props.replaceVariables} + dataLinks={dataLinks} /> ); }} diff --git a/public/app/plugins/panel/xychart/XYChartTooltip.tsx b/public/app/plugins/panel/xychart/XYChartTooltip.tsx index abd3cb32bc2..08bd3903bad 100644 --- a/public/app/plugins/panel/xychart/XYChartTooltip.tsx +++ b/public/app/plugins/panel/xychart/XYChartTooltip.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react'; -import { DataFrame, InterpolateFunction } from '@grafana/data'; +import { DataFrame, InterpolateFunction, LinkModel } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; @@ -8,7 +8,7 @@ import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizToolt import { VizTooltipWrapper } from '@grafana/ui/src/components/VizTooltip/VizTooltipWrapper'; import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; -import { getDataLinks, getFieldActions } from '../status-history/utils'; +import { getFieldActions } from '../status-history/utils'; import { XYSeries } from './types2'; import { fmt } from './utils'; @@ -21,6 +21,7 @@ export interface Props { data: DataFrame[]; xySeries: XYSeries[]; replaceVariables: InterpolateFunction; + dataLinks: LinkModel[]; } function stripSeriesName(fieldName: string, seriesName: string) { @@ -31,7 +32,16 @@ function stripSeriesName(fieldName: string, seriesName: string) { return fieldName; } -export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, isPinned, replaceVariables }: Props) => { +export const XYChartTooltip = ({ + dataIdxs, + seriesIdx, + data, + xySeries, + dismiss, + isPinned, + replaceVariables, + dataLinks, +}: Props) => { const rowIndex = dataIdxs.find((idx) => idx !== null)!; const series = xySeries[seriesIdx! - 1]; @@ -93,12 +103,15 @@ export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, i let footer: ReactNode; - if (isPinned && seriesIdx != null) { - const links = getDataLinks(yField, rowIndex); - const yFieldFrame = data.find((frame) => frame.fields.includes(yField))!; - const actions = getFieldActions(yFieldFrame, yField, replaceVariables, rowIndex); + if (seriesIdx != null) { + const hasOneClickLink = dataLinks?.some((dataLink) => dataLink.oneClick === true); - footer = ; + if (isPinned || hasOneClickLink) { + const yFieldFrame = data.find((frame) => frame.fields.includes(yField))!; + const actions = getFieldActions(yFieldFrame, yField, replaceVariables, rowIndex); + + footer = ; + } } return ( From f9c329bbd1cb5829a9a755449a61b1b4ef07d9f3 Mon Sep 17 00:00:00 2001 From: margotphelps <123196595+margotphelps@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:15:28 -0500 Subject: [PATCH 16/78] Docs: updated supported versions with 11.5 (#100548) Co-authored-by: Isabel Matwawana --- docs/sources/upgrade-guide/when-to-upgrade/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sources/upgrade-guide/when-to-upgrade/index.md b/docs/sources/upgrade-guide/when-to-upgrade/index.md index 052107861c4..31d6385ef9f 100644 --- a/docs/sources/upgrade-guide/when-to-upgrade/index.md +++ b/docs/sources/upgrade-guide/when-to-upgrade/index.md @@ -99,6 +99,7 @@ Here is an overview of projected version support through 2024: | 11.2 | August 2024 | May 2025 | | 11.3 | October 2024 | July 2025 | | 11.4 | December 2024 | September 2025 | +| 11.5 | January 2025 | October 2025 | {{< admonition type="note" >}} Grafana 9.5.x was the last supported minor for the 9.0 major release and is no longer supported as of July 2024. From 3dcd885644ab91381e4c270d432104a134f64868 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:14:52 -0600 Subject: [PATCH 17/78] Data links: Show oneClick option just for specific panels (#100298) Co-authored-by: Leon Sorokin --- .../grafana-data/src/field/overrides/processors.ts | 4 +++- packages/grafana-data/src/types/fieldOverrides.ts | 1 + .../DataLinksInlineEditor/DataLinksInlineEditor.tsx | 9 +++++++-- public/app/core/components/OptionsUI/links.tsx | 3 ++- public/app/core/components/OptionsUI/registry.tsx | 10 ++++++---- public/app/features/actions/ActionsInlineEditor.tsx | 9 +++++++-- public/app/plugins/panel/barchart/module.tsx | 8 ++++++++ .../panel/canvas/editor/element/DataLinksEditor.tsx | 2 +- public/app/plugins/panel/canvas/module.tsx | 8 ++++++++ public/app/plugins/panel/heatmap/module.tsx | 7 +++++++ public/app/plugins/panel/histogram/module.tsx | 5 +++++ public/app/plugins/panel/state-timeline/module.tsx | 8 ++++++++ public/app/plugins/panel/status-history/module.tsx | 8 ++++++++ public/app/plugins/panel/timeseries/config.ts | 8 ++++++++ public/app/plugins/panel/xychart/config.ts | 9 ++++++++- 15 files changed, 87 insertions(+), 12 deletions(-) diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts index 8c8542fd6ff..e80a73de866 100644 --- a/packages/grafana-data/src/field/overrides/processors.ts +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -50,7 +50,9 @@ export interface SliderFieldConfigSettings { ariaLabelForHandle?: string; } -export interface DataLinksFieldConfigSettings {} +export interface DataLinksFieldConfigSettings { + showOneClick?: boolean; +} export const dataLinksOverrideProcessor = ( value: any, diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index d96f22190bd..f2283c5956e 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -144,6 +144,7 @@ export enum FieldConfigProperty { Thresholds = 'thresholds', Mappings = 'mappings', Links = 'links', + Actions = 'actions', Color = 'color', Filterable = 'filterable', } diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx index 99b15ea1697..2b5a6ee7fe1 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksInlineEditor.tsx @@ -9,7 +9,12 @@ type DataLinksInlineEditorProps = Omit, getSuggestions: () => VariableSuggestion[]; }; -export const DataLinksInlineEditor = ({ links, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => ( +export const DataLinksInlineEditor = ({ + links, + getSuggestions, + showOneClick = false, + ...rest +}: DataLinksInlineEditorProps) => ( type="link" items={links} {...rest}> {(item, index, onSave, onCancel) => ( )} diff --git a/public/app/core/components/OptionsUI/links.tsx b/public/app/core/components/OptionsUI/links.tsx index 1a73b41c477..2d144b40e6c 100644 --- a/public/app/core/components/OptionsUI/links.tsx +++ b/public/app/core/components/OptionsUI/links.tsx @@ -3,13 +3,14 @@ import { DataLinksInlineEditor } from '@grafana/ui'; type Props = StandardEditorProps; -export const DataLinksValueEditor = ({ value, onChange, context }: Props) => { +export const DataLinksValueEditor = ({ value, onChange, context, item }: Props) => { return ( (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])} + showOneClick={item.settings?.showOneClick} /> ); }; diff --git a/public/app/core/components/OptionsUI/registry.tsx b/public/app/core/components/OptionsUI/registry.tsx index cf4e0450c18..519a0f0c338 100644 --- a/public/app/core/components/OptionsUI/registry.tsx +++ b/public/app/core/components/OptionsUI/registry.tsx @@ -27,6 +27,7 @@ import { FieldNamePickerConfigSettings, booleanOverrideProcessor, Action, + DataLinksFieldConfigSettings, } from '@grafana/data'; import { actionsOverrideProcessor } from '@grafana/data/src/field/overrides/processors'; import { config } from '@grafana/runtime'; @@ -350,7 +351,7 @@ export const getAllStandardFieldConfigs = () => { const dataLinksCategory = config.featureToggles.vizActions ? 'Data links and actions' : 'Data links'; - const links: FieldConfigPropertyItem = { + const links: FieldConfigPropertyItem = { id: 'links', path: 'links', name: 'Data links', @@ -358,14 +359,14 @@ export const getAllStandardFieldConfigs = () => { override: standardEditorsRegistry.get('links').editor, process: dataLinksOverrideProcessor, settings: { - placeholder: '-', + showOneClick: false, }, shouldApply: () => true, category: [dataLinksCategory], getItemsCount: (value) => (value ? value.length : 0), }; - const actions: FieldConfigPropertyItem = { + const actions: FieldConfigPropertyItem = { id: 'actions', path: 'actions', name: 'Actions', @@ -373,12 +374,13 @@ export const getAllStandardFieldConfigs = () => { override: standardEditorsRegistry.get('actions').editor, process: actionsOverrideProcessor, settings: { - placeholder: '-', + showOneClick: false, }, shouldApply: () => true, category: [dataLinksCategory], getItemsCount: (value) => (value ? value.length : 0), showIf: () => config.featureToggles.vizActions, + hideFromDefaults: true, }; const color: FieldConfigPropertyItem = { diff --git a/public/app/features/actions/ActionsInlineEditor.tsx b/public/app/features/actions/ActionsInlineEditor.tsx index 7cdb1ff34a4..9cc9550db1f 100644 --- a/public/app/features/actions/ActionsInlineEditor.tsx +++ b/public/app/features/actions/ActionsInlineEditor.tsx @@ -9,7 +9,12 @@ type DataLinksInlineEditorProps = Omit, ' getSuggestions: () => VariableSuggestion[]; }; -export const ActionsInlineEditor = ({ actions, getSuggestions, showOneClick, ...rest }: DataLinksInlineEditorProps) => ( +export const ActionsInlineEditor = ({ + actions, + getSuggestions, + showOneClick = false, + ...rest +}: DataLinksInlineEditorProps) => ( type="action" items={actions} {...rest}> {(item, index, onSave, onCancel) => ( )} diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx index 5b5ae542531..c31c17f5a81 100644 --- a/public/app/plugins/panel/barchart/module.tsx +++ b/public/app/plugins/panel/barchart/module.tsx @@ -32,6 +32,14 @@ export const plugin = new PanelPlugin(BarChartPanel) mode: FieldColorModeId.PaletteClassic, }, }, + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, + [FieldConfigProperty.Actions]: { + hideFromDefaults: false, + }, }, useCustomConfig: (builder) => { const cfg = defaultFieldConfig; diff --git a/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx b/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx index 9849b35d368..16af0cd074b 100644 --- a/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/element/DataLinksEditor.tsx @@ -20,7 +20,7 @@ export function DataLinksEditor({ value, onChange, item, context }: Props) { }} getSuggestions={() => (context.getSuggestions ? context.getSuggestions(VariableSuggestionsScope.Values) : [])} data={[]} - showOneClick={false} + showOneClick={true} /> ); } diff --git a/public/app/plugins/panel/canvas/module.tsx b/public/app/plugins/panel/canvas/module.tsx index 19b9ce04ded..9fd22ccc8c2 100644 --- a/public/app/plugins/panel/canvas/module.tsx +++ b/public/app/plugins/panel/canvas/module.tsx @@ -59,7 +59,15 @@ export const plugin = new PanelPlugin(CanvasPanel) }, }, [FieldConfigProperty.Links]: { + settings: { + showOneClick: false, + }, + }, + [FieldConfigProperty.Actions]: { hideFromDefaults: true, + settings: { + showOneClick: false, + }, }, }, }) diff --git a/public/app/plugins/panel/heatmap/module.tsx b/public/app/plugins/panel/heatmap/module.tsx index f710858e0ec..6ec45e27d9a 100644 --- a/public/app/plugins/panel/heatmap/module.tsx +++ b/public/app/plugins/panel/heatmap/module.tsx @@ -23,6 +23,13 @@ import { Options, defaultOptions, HeatmapColorMode, HeatmapColorScale } from './ export const plugin = new PanelPlugin(HeatmapPanel) .useFieldConfig({ disableStandardOptions: Object.values(FieldConfigProperty).filter((v) => v !== FieldConfigProperty.Links), + standardOptions: { + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, + }, useCustomConfig: (builder) => { builder.addCustomEditor({ id: 'scaleDistribution', diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx index c73870cf739..3be418ceb87 100644 --- a/public/app/plugins/panel/histogram/module.tsx +++ b/public/app/plugins/panel/histogram/module.tsx @@ -81,6 +81,11 @@ export const plugin = new PanelPlugin(HistogramPanel) mode: FieldColorModeId.PaletteClassic, }, }, + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, }, useCustomConfig: (builder) => { const cfg = defaultFieldConfig; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 024d1e883ac..7d84934e74a 100644 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -29,6 +29,14 @@ export const plugin = new PanelPlugin(StateTimelinePanel) mode: FieldColorModeId.ContinuousGrYlRd, }, }, + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, + [FieldConfigProperty.Actions]: { + hideFromDefaults: false, + }, }, useCustomConfig: (builder) => { builder diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx index 722f8936199..d556c52ccc0 100644 --- a/public/app/plugins/panel/status-history/module.tsx +++ b/public/app/plugins/panel/status-history/module.tsx @@ -17,6 +17,14 @@ export const plugin = new PanelPlugin(StatusHistoryPanel) mode: FieldColorModeId.Thresholds, }, }, + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, + [FieldConfigProperty.Actions]: { + hideFromDefaults: false, + }, }, useCustomConfig: (builder) => { builder diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index f749b1f6e6c..39e6a283d01 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -59,6 +59,14 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFi mode: FieldColorModeId.PaletteClassic, }, }, + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, + [FieldConfigProperty.Actions]: { + hideFromDefaults: false, + }, }, useCustomConfig: (builder) => { builder diff --git a/public/app/plugins/panel/xychart/config.ts b/public/app/plugins/panel/xychart/config.ts index c3e958c8c12..bff3ffb8774 100644 --- a/public/app/plugins/panel/xychart/config.ts +++ b/public/app/plugins/panel/xychart/config.ts @@ -35,7 +35,6 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr [FieldConfigProperty.DisplayName]: { hideFromDefaults: true, }, - // TODO: this still leaves Color series by: [ Last | Min | Max ] // because item.settings?.bySeriesSupport && colorMode.isByValue [FieldConfigProperty.Color]: { @@ -48,6 +47,14 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr mode: FieldColorModeId.PaletteClassic, }, }, + [FieldConfigProperty.Links]: { + settings: { + showOneClick: true, + }, + }, + [FieldConfigProperty.Actions]: { + hideFromDefaults: false, + }, }, useCustomConfig: (builder) => { From a34e7e176dd2a7a8b86b39e9a1eae5c77c31716d Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Wed, 12 Feb 2025 15:22:18 -0700 Subject: [PATCH 18/78] Unified Storage: Sprinkles latency metric (#100542) * add sprinkles latency metric * fixes failing tests - forgot to register metric only once --- .../unified/resource/bleve_index_metrics.go | 35 +++++++++++++++++-- pkg/storage/unified/sql/server.go | 5 +++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go index caa0499bcb8..0cb52455cff 100644 --- a/pkg/storage/unified/resource/bleve_index_metrics.go +++ b/pkg/storage/unified/resource/bleve_index_metrics.go @@ -11,8 +11,9 @@ import ( ) var ( - onceIndex sync.Once - IndexMetrics *BleveIndexMetrics + onceIndex sync.Once + IndexMetrics *BleveIndexMetrics + SprinklesIndexMetrics *SprinklesMetrics ) type BleveIndexMetrics struct { @@ -28,8 +29,30 @@ type BleveIndexMetrics struct { IndexTenants *prometheus.CounterVec } +type SprinklesMetrics struct { + SprinklesLatency prometheus.Histogram +} + var IndexCreationBuckets = []float64{1, 5, 10, 25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000} +func NewSprinklesMetrics() *SprinklesMetrics { + onceIndex.Do(func() { + SprinklesIndexMetrics = &SprinklesMetrics{ + SprinklesLatency: prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "index_server", + Name: "sprinkles_latency_seconds", + Help: "Time (in seconds) it takes until sprinkles are fetched", + Buckets: instrument.DefBuckets, + NativeHistogramBucketFactor: 1.1, // enable native histograms + NativeHistogramMaxBucketNumber: 160, + NativeHistogramMinResetDuration: time.Hour, + }), + } + }) + + return SprinklesIndexMetrics +} + func NewIndexMetrics(indexDir string, searchBackend SearchBackend) *BleveIndexMetrics { onceIndex.Do(func() { IndexMetrics = &BleveIndexMetrics{ @@ -79,6 +102,14 @@ func NewIndexMetrics(indexDir string, searchBackend SearchBackend) *BleveIndexMe return IndexMetrics } +func (s *SprinklesMetrics) Collect(ch chan<- prometheus.Metric) { + s.SprinklesLatency.Collect(ch) +} + +func (s *SprinklesMetrics) Describe(ch chan<- *prometheus.Desc) { + s.SprinklesLatency.Describe(ch) +} + func (s *BleveIndexMetrics) Collect(ch chan<- prometheus.Metric) { s.IndexLatency.Collect(ch) s.IndexCreationTime.Collect(ch) diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 2c1f1ffd602..b2cade286f5 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -83,10 +83,15 @@ func NewResourceServer(ctx context.Context, db infraDB.DB, cfg *setting.Cfg, InitMinCount: cfg.IndexMinCount, } + // Register indexer metrics err = reg.Register(resource.NewIndexMetrics(cfg.IndexPath, opts.Search.Backend)) if err != nil { slog.Warn("Failed to register indexer metrics", "error", err) } + err = reg.Register(resource.NewSprinklesMetrics()) + if err != nil { + slog.Warn("Failed to register sprinkles metrics", "error", err) + } } rs, err := resource.NewResourceServer(opts) From 0a88cb528abb7ec823b0db315e64dd5b7162a697 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Wed, 12 Feb 2025 16:51:58 -0600 Subject: [PATCH 19/78] Explore metrics: Show the native histogram banner once (#99857) * use local storage to show the native histogram banner has been loaded * remove banner logic from datatrail * set banner shown in local storage on closing the banner --- .../trails/banners/NativeHistogramBanner.test.tsx | 7 +++++++ .../trails/banners/NativeHistogramBanner.tsx | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/public/app/features/trails/banners/NativeHistogramBanner.test.tsx b/public/app/features/trails/banners/NativeHistogramBanner.test.tsx index d5191037dae..23d4d4f21d1 100644 --- a/public/app/features/trails/banners/NativeHistogramBanner.test.tsx +++ b/public/app/features/trails/banners/NativeHistogramBanner.test.tsx @@ -51,4 +51,11 @@ describe('NativeHistogramBanner', () => { fireEvent.click(histogramButton); expect(mockTrail.publishEvent).toHaveBeenCalledWith(new MetricSelectedEvent('histogram1'), true); }); + + test('Set that the banner has been shown in local storage when a user closes the banner', () => { + render(); + // click the button with aria label "Close alert" + fireEvent.click(screen.getByLabelText('Close alert')); + expect(localStorage.getItem('nativeHistogramBanner')).toBe('true'); + }); }); diff --git a/public/app/features/trails/banners/NativeHistogramBanner.tsx b/public/app/features/trails/banners/NativeHistogramBanner.tsx index 68ca35c0c67..d32375e054e 100644 --- a/public/app/features/trails/banners/NativeHistogramBanner.tsx +++ b/public/app/features/trails/banners/NativeHistogramBanner.tsx @@ -21,7 +21,7 @@ export function NativeHistogramBanner(props: NativeHistogramInfoProps) { const [showHistogramExamples, setShowHistogramExamples] = useState(false); const styles = useStyles2(getStyles, 0); - if (!histogramsLoaded || nativeHistograms.length === 0 || !histogramMessage) { + if (bannerHasBeenShown() || !histogramsLoaded || nativeHistograms.length === 0 || !histogramMessage) { return null; } @@ -32,6 +32,8 @@ export function NativeHistogramBanner(props: NativeHistogramInfoProps) { title={'Native Histogram Support'} severity={'info'} onRemove={() => { + // when a user explicitly closes the banner, save that it has been closed in local storage to not show again + setBannerHasBeenShown(); setHistogramMessage(false); }} className={styles.banner} @@ -275,3 +277,11 @@ function getStyles(theme: GrafanaTheme2, _chromeHeaderHeight: number) { }), }; } + +export function setBannerHasBeenShown() { + localStorage.setItem('nativeHistogramBanner', 'true'); +} + +export function bannerHasBeenShown() { + return localStorage.getItem('nativeHistogramBanner') ?? false; +} From 7051c5389cdbc56dde90287ae8eb9b3838ac936b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 13 Feb 2025 09:53:51 +0300 Subject: [PATCH 20/78] merge main with sprinkles stats commeted out --- pkg/storage/unified/resource/bleve_index_metrics.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go index 0cb52455cff..0ca9bd97aa1 100644 --- a/pkg/storage/unified/resource/bleve_index_metrics.go +++ b/pkg/storage/unified/resource/bleve_index_metrics.go @@ -6,8 +6,9 @@ import ( "sync" "time" - "github.com/grafana/dskit/instrument" "github.com/prometheus/client_golang/prometheus" + + "github.com/grafana/dskit/instrument" ) var ( @@ -103,11 +104,12 @@ func NewIndexMetrics(indexDir string, searchBackend SearchBackend) *BleveIndexMe } func (s *SprinklesMetrics) Collect(ch chan<- prometheus.Metric) { - s.SprinklesLatency.Collect(ch) + // s.SprinklesLatency.Collect(ch) } func (s *SprinklesMetrics) Describe(ch chan<- *prometheus.Desc) { - s.SprinklesLatency.Describe(ch) + // avoid starup panic + // s.SprinklesLatency.Describe(ch) } func (s *BleveIndexMetrics) Collect(ch chan<- prometheus.Metric) { From 2b2b19478a45bfaaea748fa04ad99121216072ed Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Thu, 13 Feb 2025 08:19:51 +0100 Subject: [PATCH 21/78] Dashboard V0->V1 Migration: Schema migration v41 (#100554) --- .../migration/schemaversion/migrations.go | 3 +- .../dashboard/migration/schemaversion/v41.go | 11 ++ .../migration/schemaversion/v41_test.go | 38 +++++ .../input/36.legend_normalization.json | 4 +- .../37.timeseries_table_display_mode.json | 4 +- .../input/38.transform_timeseries_table.json | 4 +- .../testdata/input/39.refresh_true.json | 4 +- .../input/40.time_picker_time_options.json | 136 ++++++++++++++++++ .../output/36.legend_normalization.37.json | 14 +- .../output/36.legend_normalization.38.json | 14 +- .../output/36.legend_normalization.39.json | 14 +- .../output/36.legend_normalization.40.json | 14 +- .../output/36.legend_normalization.41.json | 132 +++++++++++++++++ .../37.timeseries_table_display_mode.38.json | 14 +- .../37.timeseries_table_display_mode.39.json | 14 +- .../37.timeseries_table_display_mode.40.json | 14 +- ... 37.timeseries_table_display_mode.41.json} | 33 +++-- .../38.transform_timeseries_table.39.json | 14 +- .../38.transform_timeseries_table.40.json | 14 +- ... => 38.transform_timeseries_table.41.json} | 12 +- .../testdata/output/39.refresh_true.40.json | 14 +- ...h_true.39.json => 39.refresh_true.41.json} | 4 +- .../40.time_picker_time_options.41.json | 134 +++++++++++++++++ 23 files changed, 629 insertions(+), 30 deletions(-) create mode 100644 pkg/apis/dashboard/migration/schemaversion/v41.go create mode 100644 pkg/apis/dashboard/migration/schemaversion/v41_test.go create mode 100644 pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json create mode 100644 pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json rename pkg/apis/dashboard/migration/testdata/output/{37.timeseries_table_display_mode.37.json => 37.timeseries_table_display_mode.41.json} (91%) rename pkg/apis/dashboard/migration/testdata/output/{38.transform_timeseries_table.38.json => 38.transform_timeseries_table.41.json} (95%) rename pkg/apis/dashboard/migration/testdata/output/{39.refresh_true.39.json => 39.refresh_true.41.json} (98%) create mode 100644 pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations.go b/pkg/apis/dashboard/migration/schemaversion/migrations.go index 6d3955c329f..ef46439a591 100644 --- a/pkg/apis/dashboard/migration/schemaversion/migrations.go +++ b/pkg/apis/dashboard/migration/schemaversion/migrations.go @@ -6,7 +6,7 @@ type SchemaVersionMigrationFunc func(map[string]interface{}) error const ( MINIUM_VERSION = 36 - LATEST_VERSION = 40 + LATEST_VERSION = 41 ) var Migrations = map[int]SchemaVersionMigrationFunc{ @@ -14,6 +14,7 @@ var Migrations = map[int]SchemaVersionMigrationFunc{ 38: V38, 39: V39, 40: V40, + 41: V41, } func GetSchemaVersion(dash map[string]interface{}) int { diff --git a/pkg/apis/dashboard/migration/schemaversion/v41.go b/pkg/apis/dashboard/migration/schemaversion/v41.go new file mode 100644 index 00000000000..1faafea8285 --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v41.go @@ -0,0 +1,11 @@ +package schemaversion + +func V41(dash map[string]interface{}) error { + dash["schemaVersion"] = int(41) + if timepicker, ok := dash["timepicker"].(map[string]interface{}); ok { + // time_options is a legacy property that was not used since grafana version 5 + // therefore deprecating this property from the schema + delete(timepicker, "time_options") + } + return nil +} diff --git a/pkg/apis/dashboard/migration/schemaversion/v41_test.go b/pkg/apis/dashboard/migration/schemaversion/v41_test.go new file mode 100644 index 00000000000..d0f11d227f3 --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v41_test.go @@ -0,0 +1,38 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion" +) + +func TestV41(t *testing.T) { + tests := []migrationTestCase{ + { + name: "time_options is removed", + input: map[string]interface{}{ + "title": "Test Dashboard", + "timepicker": map[string]interface{}{ + "time_options": []string{"1m", "5m", "15m", "1h", "6h", "12h", "24h"}, + }, + }, + expected: map[string]interface{}{ + "title": "Test Dashboard", + "schemaVersion": 41, + "timepicker": map[string]interface{}{}, + }, + }, + { + name: "timepicker is not set", + input: map[string]interface{}{ + "title": "Test Dashboard", + }, + expected: map[string]interface{}{ + "title": "Test Dashboard", + "schemaVersion": 41, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V41) +} diff --git a/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json b/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json index 93ddfecb078..7776ccdf0cb 100644 --- a/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json +++ b/pkg/apis/dashboard/migration/testdata/input/36.legend_normalization.json @@ -115,7 +115,9 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json b/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json index e800450fd51..20fa6fc0371 100644 --- a/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json +++ b/pkg/apis/dashboard/migration/testdata/input/37.timeseries_table_display_mode.json @@ -352,7 +352,9 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json b/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json index e5f08f17c69..9afbe33c607 100644 --- a/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json +++ b/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json @@ -145,7 +145,9 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json b/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json index 4ea2be531f9..844bd81eb23 100644 --- a/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json +++ b/pkg/apis/dashboard/migration/testdata/input/39.refresh_true.json @@ -124,7 +124,9 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json b/pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json new file mode 100644 index 00000000000..5b6071de054 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/input/40.time_picker_time_options.json @@ -0,0 +1,136 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + } + ], + "preload": false, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "", + "refresh": "", + "schemaVersion": 40 + } \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json index 1e6e0484aad..27e61667230 100644 --- a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.37.json @@ -124,7 +124,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json index 14c5c32071f..de4220f3b61 100644 --- a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.38.json @@ -124,7 +124,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json index 5e3239e89fa..472dbdd70a7 100644 --- a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.39.json @@ -124,7 +124,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json index 91d625264b0..e097b1e8402 100644 --- a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.40.json @@ -124,7 +124,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json new file mode 100644 index 00000000000..fda6883a462 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/36.legend_normalization.41.json @@ -0,0 +1,132 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "title": "No Legend Config", + "type": "graph" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "list", + "showLegend": true + } + }, + "title": "Boolean Legend True" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Boolean Legend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "Hidden DisplayMode" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "showLegend": false + } + }, + "title": "ShowLegend False" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "displayMode": "table", + "showLegend": true + } + }, + "title": "Visible Legend" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json index 30f64b0bc8e..5a17d602e82 100644 --- a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.38.json @@ -367,7 +367,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json index 1173963dc58..dee3d62af57 100644 --- a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.39.json @@ -367,7 +367,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json index 843cc6284f7..d5ddc2a55a8 100644 --- a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.40.json @@ -367,7 +367,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.41.json similarity index 91% rename from pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json rename to pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.41.json index 4b7c2fa4572..2d1d083a874 100644 --- a/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.37.json +++ b/pkg/apis/dashboard/migration/testdata/output/37.timeseries_table_display_mode.41.json @@ -31,7 +31,10 @@ "mode": "palette-classic" }, "custom": { - "displayMode": "basic" + "cellOptions": { + "mode": "basic", + "type": "gauge" + } }, "mappings": [], "thresholds": { @@ -84,7 +87,10 @@ "mode": "palette-classic" }, "custom": { - "displayMode": "gradient-gauge" + "cellOptions": { + "mode": "gradient", + "type": "gauge" + } }, "mappings": [], "thresholds": { @@ -137,7 +143,10 @@ "mode": "palette-classic" }, "custom": { - "displayMode": "lcd-gauge" + "cellOptions": { + "mode": "lcd", + "type": "gauge" + } }, "mappings": [], "thresholds": { @@ -190,7 +199,10 @@ "mode": "palette-classic" }, "custom": { - "displayMode": "color-background" + "cellOptions": { + "mode": "gradient", + "type": "color-background" + } }, "mappings": [], "thresholds": { @@ -243,7 +255,10 @@ "mode": "palette-classic" }, "custom": { - "displayMode": "color-background-solid" + "cellOptions": { + "mode": "basic", + "type": "color-background" + } }, "mappings": [], "thresholds": { @@ -296,7 +311,9 @@ "mode": "palette-classic" }, "custom": { - "displayMode": "some-other-mode" + "cellOptions": { + "type": "some-other-mode" + } }, "mappings": [], "thresholds": { @@ -340,8 +357,8 @@ } ], "preload": false, - "refresh": true, - "schemaVersion": 37, + "refresh": "", + "schemaVersion": 41, "tags": [], "templating": { "list": [] diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json index 136a2fb9d40..19b3b5d79f8 100644 --- a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json +++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json @@ -147,7 +147,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json index 4217f847e8c..63b0959daa1 100644 --- a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json +++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json @@ -147,7 +147,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.41.json similarity index 95% rename from pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json rename to pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.41.json index 081cb14634f..be300a117c6 100644 --- a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.38.json +++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.41.json @@ -124,9 +124,11 @@ { "id": "timeSeriesTable", "options": { - "refIdToStat": { - "A": "mean", - "B": "max" + "A": { + "stat": "mean" + }, + "B": { + "stat": "max" } } } @@ -135,8 +137,8 @@ } ], "preload": false, - "refresh": true, - "schemaVersion": 38, + "refresh": "", + "schemaVersion": 41, "tags": [], "templating": { "list": [] diff --git a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json index a8c67a1e80d..5c4ad99f35c 100644 --- a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json +++ b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.40.json @@ -126,7 +126,19 @@ "from": "now-6h", "to": "now" }, - "timepicker": {}, + "timepicker": { + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, "timezone": "utc", "title": "New dashboard", "version": 0, diff --git a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.41.json similarity index 98% rename from pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json rename to pkg/apis/dashboard/migration/testdata/output/39.refresh_true.41.json index 6c0cb4d1974..8c6dfe8ba06 100644 --- a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json +++ b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.41.json @@ -116,8 +116,8 @@ } ], "preload": false, - "refresh": true, - "schemaVersion": 39, + "refresh": "", + "schemaVersion": 41, "tags": [], "templating": { "list": [] diff --git a/pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json b/pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json new file mode 100644 index 00000000000..8c6dfe8ba06 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/40.time_picker_time_options.41.json @@ -0,0 +1,134 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.0-81438", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "New dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file From 2dee9ccbbcd0a01c2f0afcc89c5e0f5f03aa602a Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Thu, 13 Feb 2025 08:54:58 +0100 Subject: [PATCH 22/78] APIServer: Cancel forked context after handler returns (#100504) We currently cancel the context when the adapter function is done. We should wait for the entire handler we're wrapping to finish before cancelling our context. --- pkg/apiserver/endpoints/responsewriter/responsewriter.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/apiserver/endpoints/responsewriter/responsewriter.go b/pkg/apiserver/endpoints/responsewriter/responsewriter.go index a076340d7bb..824cc05a17e 100644 --- a/pkg/apiserver/endpoints/responsewriter/responsewriter.go +++ b/pkg/apiserver/endpoints/responsewriter/responsewriter.go @@ -37,11 +37,12 @@ func WrapHandler(handler http.Handler) func(req *http.Request) (*http.Response, if err != nil { return nil, err } - defer cancel() + // The cancel happens in the goroutine we spawn, so as to not cancel it too early. req = req.WithContext(ctx) // returns a shallow copy, so we can't do it as part of the adapter. w := NewAdapter(req) go func() { + defer cancel() handler.ServeHTTP(w, req) if err := w.CloseWriter(); err != nil { klog.Errorf("error closing writer: %v", err) From df64dd076243e25808b4d6384b96d6ddb338e59a Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:17:16 +0100 Subject: [PATCH 23/78] LibraryElements: Propagate service identity in context when searching for dashboards (#100220) * Propagate service identity in context when searching for dashboards --- pkg/services/libraryelements/database.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 306352fce84..d133c36e650 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -250,14 +250,17 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn return err } - // then find the dashboards that were supposed to be connected to this element - _, requester := identity.WithServiceIdentity(c, signedInUser.GetOrgID()) - dashs, err := l.dashboardsService.FindDashboards(c, &dashboards.FindPersistedDashboardsQuery{ + // then find the dashboards that were supposed to be connected to this element. + // A identity may be able to delete a library element but not read all dashboards so we fetch then as the + // service user so we can prevent deletion of those connections + serviceCtx, serviceIdent := identity.WithServiceIdentity(c, signedInUser.GetOrgID()) + dashs, err := l.dashboardsService.FindDashboards(serviceCtx, &dashboards.FindPersistedDashboardsQuery{ Type: searchstore.TypeDashboard, - OrgId: signedInUser.GetOrgID(), + OrgId: serviceIdent.GetOrgID(), DashboardIds: dashboardIDs, - SignedInUser: requester, // a user may be able to delete a library element but not read all dashboards. We still need to run this check, so we don't allow deleting elements if dashboards are connected + SignedInUser: serviceIdent, }) + if err != nil { return err } From fbf96916aa23a83346c3955c38914032300b9a29 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 13 Feb 2025 09:20:45 +0100 Subject: [PATCH 24/78] Alerting: Use alerting-specific error boundary for page components (#99980) Use alerting-specific error boundary for page components --- .../ErrorBoundary/ErrorBoundary.tsx | 15 ++++-- .../features/alerting/unified/AlertGroups.tsx | 15 +++--- .../alerting/unified/AlertingNotEnabled.tsx | 6 ++- .../alerting/unified/NewSilencePage.tsx | 5 +- .../unified/NotificationPoliciesPage.tsx | 17 +++--- .../alerting/unified/RedirectToRuleViewer.tsx | 5 +- .../features/alerting/unified/RuleList.tsx | 3 +- .../features/alerting/unified/RuleViewer.tsx | 5 +- .../features/alerting/unified/Settings.tsx | 5 +- .../features/alerting/unified/Templates.tsx | 41 +++++++------- .../contact-points/ContactPoints.tsx | 4 +- .../DuplicateMessageTemplate.tsx | 14 ++++- .../contact-points/EditContactPoint.tsx | 5 +- .../contact-points/EditMessageTemplate.tsx | 14 ++++- .../contact-points/NewMessageTemplate.tsx | 20 +++---- .../components/GlobalConfig.tsx | 11 ++-- .../export/ExportNewGrafanaRule.tsx | 21 ++------ .../components/export/GrafanaModifyExport.tsx | 54 ++++++++----------- .../mute-timings/EditMuteTiming.tsx | 27 +++++----- .../components/mute-timings/NewMuteTiming.tsx | 28 +++++----- .../components/receivers/NewReceiverView.tsx | 4 +- .../CentralAlertHistoryPage.tsx | 10 ++-- .../components/silences/SilencesEditor.tsx | 5 +- .../components/silences/SilencesTable.tsx | 5 +- .../features/alerting/unified/home/Home.tsx | 5 +- .../unified/rule-editor/RuleEditor.tsx | 6 ++- .../unified/rule-list/RuleList.v1.tsx | 4 +- .../unified/rule-list/RuleList.v2.tsx | 39 +++++++------- .../unified/withPageErrorBoundary.tsx | 25 +++++++++ 29 files changed, 238 insertions(+), 180 deletions(-) create mode 100644 public/app/features/alerting/unified/withPageErrorBoundary.tsx diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx index 312d6e76029..8c50da47478 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -21,6 +21,8 @@ interface Props { onError?: (error: Error) => void; /** Callback error state is cleared due to recover props change */ onRecover?: () => void; + /** Default error logger - Faro by default */ + errorLogger?: (error: Error) => void; } interface State { @@ -35,7 +37,12 @@ export class ErrorBoundary extends PureComponent { }; componentDidCatch(error: Error, errorInfo: ErrorInfo) { - faro?.api?.pushError(error); + const logger = this.props.errorLogger ?? faro?.api?.pushError; + + if (logger) { + logger(error); + } + this.setState({ error, errorInfo }); if (this.props.onError) { @@ -89,6 +96,8 @@ export interface ErrorBoundaryAlertProps { /** Will re-render children after error if recover values changes */ dependencies?: unknown[]; + /** Default error logger - Faro by default */ + errorLogger?: (error: Error) => void; } export class ErrorBoundaryAlert extends PureComponent { @@ -98,10 +107,10 @@ export class ErrorBoundaryAlert extends PureComponent { }; render() { - const { title, children, style, dependencies } = this.props; + const { title, children, style, dependencies, errorLogger } = this.props; return ( - + {({ error, errorInfo }) => { if (!errorInfo) { return children; diff --git a/public/app/features/alerting/unified/AlertGroups.tsx b/public/app/features/alerting/unified/AlertGroups.tsx index 3422a093990..2d5863f7aea 100644 --- a/public/app/features/alerting/unified/AlertGroups.tsx +++ b/public/app/features/alerting/unified/AlertGroups.tsx @@ -19,6 +19,7 @@ import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { getFiltersFromUrlParams } from './utils/misc'; import { initialAsyncRequestState } from './utils/redux'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; const AlertGroups = () => { const { selectedAlertmanager } = useAlertmanager(); @@ -89,10 +90,12 @@ const AlertGroups = () => { ); }; -const AlertGroupsPage = () => ( - - - -); +function AlertGroupsPage() { + return ( + + + + ); +} -export default AlertGroupsPage; +export default withPageErrorBoundary(AlertGroupsPage); diff --git a/public/app/features/alerting/unified/AlertingNotEnabled.tsx b/public/app/features/alerting/unified/AlertingNotEnabled.tsx index ca8a10aa487..9250fafed2c 100644 --- a/public/app/features/alerting/unified/AlertingNotEnabled.tsx +++ b/public/app/features/alerting/unified/AlertingNotEnabled.tsx @@ -1,7 +1,9 @@ import { NavModel } from '@grafana/data'; import { Page } from 'app/core/components/Page/Page'; -export default function FeatureTogglePage() { +import { withPageErrorBoundary } from './withPageErrorBoundary'; + +function FeatureTogglePage() { const navModel: NavModel = { node: { text: 'Alerting is not enabled', @@ -25,3 +27,5 @@ enabled = true ); } + +export default withPageErrorBoundary(FeatureTogglePage); diff --git a/public/app/features/alerting/unified/NewSilencePage.tsx b/public/app/features/alerting/unified/NewSilencePage.tsx index 859b9c3875e..2f2dc4e79cd 100644 --- a/public/app/features/alerting/unified/NewSilencePage.tsx +++ b/public/app/features/alerting/unified/NewSilencePage.tsx @@ -1,6 +1,5 @@ import { useLocation } from 'react-router-dom-v5-compat'; -import { withErrorBoundary } from '@grafana/ui'; import { defaultsFromQuery, getDefaultSilenceFormValues, @@ -12,6 +11,7 @@ import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; import { SilencesEditor } from './components/silences/SilencesEditor'; import { useAlertmanager } from './state/AlertmanagerContext'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; const SilencesEditorComponent = () => { const location = useLocation(); @@ -48,4 +48,5 @@ function NewSilencePage() { ); } -export default withErrorBoundary(NewSilencePage, { style: 'page' }); + +export default withPageErrorBoundary(NewSilencePage); diff --git a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx index 6b4d1b9b5e7..25d2ef8cbf8 100644 --- a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx +++ b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useState } from 'react'; import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; -import { Tab, TabContent, TabsBar, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings'; import { NotificationPoliciesList } from 'app/features/alerting/unified/components/notification-policies/NotificationPoliciesList'; @@ -12,6 +12,7 @@ import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; import { MuteTimingsTable } from './components/mute-timings/MuteTimingsTable'; import { useAlertmanager } from './state/AlertmanagerContext'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; enum ActiveTab { NotificationPolicies = 'notification_policies', @@ -104,10 +105,12 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q }; } -const NotificationPoliciesPage = () => ( - - - -); +function NotificationPoliciesPage() { + return ( + + + + ); +} -export default withErrorBoundary(NotificationPoliciesPage, { style: 'page' }); +export default withPageErrorBoundary(NotificationPoliciesPage); diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx index e5375ad9133..bb032b5a9fd 100644 --- a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx +++ b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx @@ -5,7 +5,7 @@ import { useLocation } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { config, isFetchError } from '@grafana/runtime'; -import { Alert, Card, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { Alert, Card, Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { AlertLabels } from './components/AlertLabels'; import { RuleViewerLayout } from './components/rule-viewer/RuleViewerLayout'; @@ -13,6 +13,7 @@ import { useCloudCombinedRulesMatching } from './hooks/useCombinedRule'; import { getRulesSourceByName } from './utils/datasource'; import { createViewLink } from './utils/misc'; import { unescapePathSeparators } from './utils/rule-id'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; const pageTitle = 'Find rule'; const subUrl = config.appSubUrl; @@ -153,4 +154,4 @@ function getStyles(theme: GrafanaTheme2) { }; } -export default withErrorBoundary(RedirectToRuleViewer, { style: 'page' }); +export default withPageErrorBoundary(RedirectToRuleViewer); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index 9c38d4bf049..f51f440bd14 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -3,6 +3,7 @@ import { Suspense, lazy } from 'react'; import { config } from '@grafana/runtime'; import RuleListV1 from './rule-list/RuleList.v1'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; const RuleListV2 = lazy(() => import('./rule-list/RuleList.v2')); const RuleList = () => { @@ -11,4 +12,4 @@ const RuleList = () => { return {newView ? : }; }; -export default RuleList; +export default withPageErrorBoundary(RuleList); diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index 9584539e41b..9df84a41b18 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom-v5-compat'; import { NavModelItem } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; -import { Alert, withErrorBoundary } from '@grafana/ui'; +import { Alert } from '@grafana/ui'; import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; @@ -12,6 +12,7 @@ import DetailView, { ActiveTab, useActiveTab } from './components/rule-viewer/Ru import { useCombinedRule } from './hooks/useCombinedRule'; import { stringifyErrorLike } from './utils/misc'; import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; const RuleViewer = (): JSX.Element => { const params = useParams(); @@ -86,4 +87,4 @@ function ErrorMessage({ error }: ErrorMessageProps) { return {stringifyErrorLike(error)}; } -export default withErrorBoundary(RuleViewer, { style: 'page' }); +export default withPageErrorBoundary(RuleViewer); diff --git a/public/app/features/alerting/unified/Settings.tsx b/public/app/features/alerting/unified/Settings.tsx index 099bde572b0..7d3a91dafdf 100644 --- a/public/app/features/alerting/unified/Settings.tsx +++ b/public/app/features/alerting/unified/Settings.tsx @@ -6,8 +6,9 @@ import { useEditConfigurationDrawer } from './components/settings/ConfigurationD import { ExternalAlertmanagers } from './components/settings/ExternalAlertmanagers'; import InternalAlertmanager from './components/settings/InternalAlertmanager'; import { SettingsProvider, useSettings } from './components/settings/SettingsContext'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; -export default function SettingsPage() { +function SettingsPage() { return ( @@ -47,3 +48,5 @@ function SettingsContent() { ); } + +export default withPageErrorBoundary(SettingsPage); diff --git a/public/app/features/alerting/unified/Templates.tsx b/public/app/features/alerting/unified/Templates.tsx index d32ea0541b4..f3b8b011f36 100644 --- a/public/app/features/alerting/unified/Templates.tsx +++ b/public/app/features/alerting/unified/Templates.tsx @@ -1,28 +1,29 @@ import { Route, Routes } from 'react-router-dom-v5-compat'; -import { withErrorBoundary } from '@grafana/ui'; - import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate'; import EditMessageTemplate from './components/contact-points/EditMessageTemplate'; import NewMessageTemplate from './components/contact-points/NewMessageTemplate'; +import { withPageErrorBoundary } from './withPageErrorBoundary'; -const NotificationTemplates = (): JSX.Element => ( - - - } /> - } /> - } /> - - -); +function NotificationTemplates() { + return ( + + + } /> + } /> + } /> + + + ); +} -export default withErrorBoundary(NotificationTemplates, { style: 'page' }); +export default withPageErrorBoundary(NotificationTemplates); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index 5ea42c97584..0d2f6cf447e 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -12,7 +12,6 @@ import { TabContent, TabsBar, Text, - withErrorBoundary, } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { Trans, t } from 'app/core/internationalization'; @@ -25,6 +24,7 @@ import { usePagination } from '../../hooks/usePagination'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; @@ -270,4 +270,4 @@ function ContactPointsPage() { ); } -export default withErrorBoundary(ContactPointsPage, { style: 'page' }); +export default withPageErrorBoundary(ContactPointsPage); diff --git a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx index 3ded1052b5d..bc89effea98 100644 --- a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx +++ b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx @@ -8,13 +8,15 @@ import { useAlertmanager } from '../../state/AlertmanagerContext'; import { generateCopiedName } from '../../utils/duplicate'; import { stringifyErrorLike } from '../../utils/misc'; import { updateDefinesWithUniqueValue } from '../../utils/templates'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; +import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { TemplateForm } from '../receivers/TemplateForm'; import { useGetNotificationTemplate, useNotificationTemplates } from './useNotificationTemplates'; const notFoundComponent = ; -const DuplicateMessageTemplate = () => { +const DuplicateMessageTemplateComponent = () => { const { selectedAlertmanager } = useAlertmanager(); const { name } = useParams<{ name: string }>(); const templateUid = name ? decodeURIComponent(name) : undefined; @@ -63,4 +65,12 @@ const DuplicateMessageTemplate = () => { ); }; -export default DuplicateMessageTemplate; +function DuplicateMessageTemplate() { + return ( + + + + ); +} + +export default withPageErrorBoundary(DuplicateMessageTemplate); diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx index 96a00fc1d33..5e9deb4b3da 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx @@ -1,10 +1,11 @@ import { useParams } from 'react-router-dom-v5-compat'; -import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { useGetContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { EditReceiverView } from '../receivers/EditReceiverView'; @@ -50,4 +51,4 @@ function EditContactPointPage() { ); } -export default withErrorBoundary(EditContactPointPage, { style: 'page' }); +export default withPageErrorBoundary(EditContactPointPage); diff --git a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx index 6c7df2a7fad..926e1c5404b 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx @@ -6,13 +6,15 @@ import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound' import { isNotFoundError } from '../../api/util'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { stringifyErrorLike } from '../../utils/misc'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; +import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { TemplateForm } from '../receivers/TemplateForm'; import { useGetNotificationTemplate } from './useNotificationTemplates'; const notFoundComponent = ; -const EditMessageTemplate = () => { +const EditMessageTemplateComponent = () => { const { name } = useParams<{ name: string }>(); const templateUid = name ? decodeURIComponent(name) : undefined; @@ -47,4 +49,12 @@ const EditMessageTemplate = () => { return ; }; -export default EditMessageTemplate; +function EditMessageTemplate() { + return ( + + + + ); +} + +export default withPageErrorBoundary(EditMessageTemplate); diff --git a/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx index e11ac531390..7cdd2286be1 100644 --- a/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx +++ b/public/app/features/alerting/unified/components/contact-points/NewMessageTemplate.tsx @@ -1,16 +1,16 @@ -import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; - import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; +import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { TemplateForm } from '../receivers/TemplateForm'; -const NewMessageTemplate = () => { +function NewMessageTemplate() { const { selectedAlertmanager } = useAlertmanager(); - if (!selectedAlertmanager) { - return ; - } + return ( + + + + ); +} - return ; -}; - -export default NewMessageTemplate; +export default withPageErrorBoundary(NewMessageTemplate); diff --git a/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx b/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx index e5514b01d66..42ec581c529 100644 --- a/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx +++ b/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx @@ -1,11 +1,12 @@ -import { Alert, withErrorBoundary } from '@grafana/ui'; +import { Alert } from '@grafana/ui'; import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig'; import { useAlertmanager } from '../../../state/AlertmanagerContext'; +import { withPageErrorBoundary } from '../../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../../AlertingPageWrapper'; import { GlobalConfigForm } from '../../receivers/GlobalConfigForm'; -const NewMessageTemplate = () => { +const GlobalConfig = () => { const { selectedAlertmanager } = useAlertmanager(); const { data, isLoading, error } = useAlertmanagerConfig(selectedAlertmanager); @@ -28,12 +29,12 @@ const NewMessageTemplate = () => { return ; }; -function NewMessageTemplatePage() { +function GlobalConfigPage() { return ( - + ); } -export default withErrorBoundary(NewMessageTemplatePage, { style: 'page' }); +export default withPageErrorBoundary(GlobalConfigPage); diff --git a/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx b/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx index 2e99bc0dc41..614f74eaa2a 100644 --- a/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx +++ b/public/app/features/alerting/unified/components/export/ExportNewGrafanaRule.tsx @@ -1,21 +1,8 @@ -import * as React from 'react'; - +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertingPageWrapper } from '../AlertingPageWrapper'; import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm'; -export default function ExportNewGrafanaRule() { - return ( - - - - ); -} - -interface ExportNewGrafanaRuleWrapperProps { - children: React.ReactNode; -} - -function ExportNewGrafanaRuleWrapper({ children }: ExportNewGrafanaRuleWrapperProps) { +function ExportNewGrafanaRulePage() { return ( - {children} + ); } + +export default withPageErrorBoundary(ExportNewGrafanaRulePage); diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx index 5aca1d77824..1c4241fe519 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.tsx @@ -1,4 +1,3 @@ -import * as React from 'react'; import { useMemo } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; @@ -12,10 +11,11 @@ import { stringifyErrorLike } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; import { isGrafanaRulerRule } from '../../utils/rules'; import { createRelativeUrl } from '../../utils/url'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertingPageWrapper } from '../AlertingPageWrapper'; import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm'; -export default function GrafanaModifyExport() { +function GrafanaModifyExport() { const { id } = useParams(); const ruleIdentifier = useMemo(() => { return ruleId.tryParse(id, true); @@ -23,38 +23,13 @@ export default function GrafanaModifyExport() { if (!ruleIdentifier) { return ( - - - The rule UID in the page URL is invalid. Please check the URL and try again. - - + + The rule UID in the page URL is invalid. Please check the URL and try again. + ); } - return ( - - - - ); -} - -interface ModifyExportWrapperProps { - children: React.ReactNode; -} - -function ModifyExportWrapper({ children }: ModifyExportWrapperProps) { - return ( - - {children} - - ); + return ; } function RuleModifyExport({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }) { @@ -105,3 +80,20 @@ function RuleModifyExport({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier } return ; } + +function GrafanaModifyExportPage() { + return ( + + + + ); +} + +export default withPageErrorBoundary(GrafanaModifyExportPage); diff --git a/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx b/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx index f73b1a1d1af..f697b935408 100644 --- a/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/EditMuteTiming.tsx @@ -1,10 +1,10 @@ import { Navigate } from 'react-router-dom-v5-compat'; -import { withErrorBoundary } from '@grafana/ui'; import { useGetMuteTiming } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings'; import { useURLSearchParams } from 'app/features/alerting/unified/hooks/useURLSearchParams'; import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import MuteTimingForm from './MuteTimingForm'; @@ -38,17 +38,16 @@ const EditTimingRoute = () => { ); }; -const EditMuteTimingPage = () => ( - - - -); +function EditMuteTimingPage() { + return ( + + + + ); +} -export default withErrorBoundary(EditMuteTimingPage, { style: 'page' }); +export default withPageErrorBoundary(EditMuteTimingPage); diff --git a/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx b/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx index b73dda0e94f..c60e6d0f990 100644 --- a/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/NewMuteTiming.tsx @@ -1,20 +1,18 @@ -import { withErrorBoundary } from '@grafana/ui'; - +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import MuteTimingForm from './MuteTimingForm'; -const NewMuteTimingPage = () => ( - - - -); +function NewMuteTimingPage() { + return ( + + + + ); +} -export default withErrorBoundary(NewMuteTimingPage, { style: 'page' }); +export default withPageErrorBoundary(NewMuteTimingPage); diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx index 4f2a3322da4..9258dddfc89 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx @@ -1,7 +1,7 @@ -import { withErrorBoundary } from '@grafana/ui'; import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { CloudReceiverForm } from './form/CloudReceiverForm'; @@ -24,4 +24,4 @@ function NewReceiverViewPage() { ); } -export default withErrorBoundary(NewReceiverViewPage, { style: 'page' }); +export default withPageErrorBoundary(NewReceiverViewPage); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx index eb767a1df2f..91463309afd 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx @@ -1,14 +1,14 @@ -import { withErrorBoundary } from '@grafana/ui'; - +import { withPageErrorBoundary } from '../../../withPageErrorBoundary'; import { AlertingPageWrapper } from '../../AlertingPageWrapper'; import { CentralAlertHistoryScene } from './CentralAlertHistoryScene'; -const HistoryPage = () => { +function HistoryPage() { return ( ); -}; -export default withErrorBoundary(HistoryPage, { style: 'page' }); +} + +export default withPageErrorBoundary(HistoryPage); diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index c72cb1e5521..2a1c1be335c 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -25,7 +25,6 @@ import { Stack, TextArea, useStyles2, - withErrorBoundary, } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { SilenceCreatedResponse, alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi'; @@ -38,6 +37,7 @@ import { useAlertmanager } from '../../state/AlertmanagerContext'; import { SilenceFormFields } from '../../types/silence-form'; import { matcherFieldToMatcher } from '../../utils/alertmanager'; import { makeAMLink } from '../../utils/misc'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; @@ -296,4 +296,5 @@ function ExistingSilenceEditorPage() { ); } -export default withErrorBoundary(ExistingSilenceEditorPage, { style: 'page' }); + +export default withPageErrorBoundary(ExistingSilenceEditorPage); diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 6eaf819d11a..2faa5a2b1c8 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -12,7 +12,6 @@ import { LoadingPlaceholder, Stack, useStyles2, - withErrorBoundary, } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { Trans } from 'app/core/internationalization'; @@ -27,6 +26,7 @@ import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbili import { useAlertmanager } from '../../state/AlertmanagerContext'; import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers'; import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { Authorize } from '../Authorize'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; @@ -393,4 +393,5 @@ function SilencesTablePage() { ); } -export default withErrorBoundary(SilencesTablePage, { style: 'page' }); + +export default withPageErrorBoundary(SilencesTablePage); diff --git a/public/app/features/alerting/unified/home/Home.tsx b/public/app/features/alerting/unified/home/Home.tsx index 898bfdebefb..023b6a237a3 100644 --- a/public/app/features/alerting/unified/home/Home.tsx +++ b/public/app/features/alerting/unified/home/Home.tsx @@ -5,12 +5,13 @@ import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import { isLocalDevEnv } from '../utils/misc'; +import { withPageErrorBoundary } from '../withPageErrorBoundary'; import GettingStarted, { WelcomeHeader } from './GettingStarted'; import { getInsightsScenes, insightsIsAvailable } from './Insights'; import { PluginIntegrations } from './PluginIntegrations'; -export default function Home() { +function Home() { const insightsEnabled = (insightsIsAvailable() || isLocalDevEnv()) && Boolean(config.featureToggles.alertingInsights); const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview'); @@ -51,3 +52,5 @@ export default function Home() { ); } + +export default withPageErrorBoundary(Home); diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx index b5adba57cf2..42585600052 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx @@ -2,7 +2,6 @@ import { useCallback } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; import { NavModelItem } from '@grafana/data'; -import { withErrorBoundary } from '@grafana/ui'; import { RuleIdentifier } from 'app/types/unified-alerting'; import { AlertWarning } from '../AlertWarning'; @@ -11,6 +10,7 @@ import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRu import { useURLSearchParams } from '../hooks/useURLSearchParams'; import { useRulesAccess } from '../utils/accessControlHooks'; import * as ruleId from '../utils/rule-id'; +import { withPageErrorBoundary } from '../withPageErrorBoundary'; import { CloneRuleEditor } from './CloneRuleEditor'; import { ExistingRuleEditor } from './ExistingRuleEditor'; @@ -78,7 +78,9 @@ const RuleEditor = () => { ); }; -export default withErrorBoundary(RuleEditor, { style: 'page' }); +// The pageNav property makes it difficult to only rely on AlertingPageWrapper +// to catch errors. +export default withPageErrorBoundary(RuleEditor); function useRuleEditorPathParams() { const params = useParams(); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx index d90c4b238c0..759e7b48474 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx @@ -4,7 +4,7 @@ import { useAsyncFn, useInterval } from 'react-use'; import { urlUtil } from '@grafana/data'; import { logInfo } from '@grafana/runtime'; -import { Button, LinkButton, Stack, withErrorBoundary } from '@grafana/ui'; +import { Button, LinkButton, Stack } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { Trans } from 'app/core/internationalization'; import { useDispatch } from 'app/types'; @@ -155,7 +155,7 @@ const RuleListV1 = () => { ); }; -export default withErrorBoundary(RuleListV1, { style: 'page' }); +export default RuleListV1; export function CreateAlertButton() { const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index b8ccbb2fa8f..86949386e3a 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -1,5 +1,3 @@ -import { withErrorBoundary } from '@grafana/ui'; - import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import RulesFilter from '../components/rules/Filter/RulesFilter'; import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector'; @@ -9,24 +7,25 @@ import { useURLSearchParams } from '../hooks/useURLSearchParams'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; -const RuleList = withErrorBoundary( - () => { - const [queryParams] = useURLSearchParams(); - const { filterState, hasActiveFilters } = useRulesFilter(); +function RuleList() { + const [queryParams] = useURLSearchParams(); + const { filterState, hasActiveFilters } = useRulesFilter(); - const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped'; - const showListView = hasActiveFilters || view === 'list'; + const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped'; + const showListView = hasActiveFilters || view === 'list'; - return ( - // We don't want to show the Loading... indicator for the whole page. - // We show separate indicators for Grafana-managed and Cloud rules - - {}} /> - {showListView ? : } - - ); - }, - { style: 'page' } -); + return ( + <> + {}} /> + {showListView ? : } + + ); +} -export default RuleList; +export default function RuleListPage() { + return ( + + + + ); +} diff --git a/public/app/features/alerting/unified/withPageErrorBoundary.tsx b/public/app/features/alerting/unified/withPageErrorBoundary.tsx new file mode 100644 index 00000000000..e53d5bb2e0d --- /dev/null +++ b/public/app/features/alerting/unified/withPageErrorBoundary.tsx @@ -0,0 +1,25 @@ +import { ComponentType } from 'react'; + +import { ErrorBoundaryAlertProps, withErrorBoundary } from '@grafana/ui'; + +import { logError } from './Analytics'; + +/** + * HOC for wrapping alerting page in an error boundary. + * It provides alerting-specific error handling. + * + * @param Component - the react component to wrap in error boundary + * @param errorBoundaryProps - error boundary options + * + * @public + */ +export function withPageErrorBoundary

      ( + Component: ComponentType

      , + errorBoundaryProps: Omit = {} +): ComponentType

      { + return withErrorBoundary(Component, { + ...errorBoundaryProps, + style: 'page', + errorLogger: logError, + }); +} From 8a8e47fceac329abb9eebbf6dfde06893497d197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20S=C3=BC=C3=9F?= Date: Thu, 13 Feb 2025 10:18:55 +0100 Subject: [PATCH 25/78] PluginExtensions: Added support for sharing functions (#98888) * feat: add generic plugin extension functions * updated betterer. * Fixed type issues after sync with main. * Remved extensions from datasource and panel. * Added validation for extension function registry. * Added tests and validation logic for function extensions registry. * removed prop already existing on base. * fixed lint error. --------- Co-authored-by: Marcus Andersson --- .betterer.results | 6 + packages/grafana-data/src/index.ts | 2 + packages/grafana-data/src/types/app.ts | 12 + packages/grafana-data/src/types/plugin.ts | 2 + .../src/types/pluginExtensions.ts | 19 +- .../grafana-runtime/src/services/index.ts | 3 + .../pluginExtensions/getPluginExtensions.ts | 17 +- .../pluginExtensions/usePluginFunctions.ts | 20 + .../manager/loader/finder/local_test.go | 43 +- pkg/plugins/manager/loader/loader_test.go | 25 +- pkg/plugins/models.go | 8 + pkg/plugins/plugins.go | 4 + pkg/plugins/plugins_test.go | 34 +- .../pluginsintegration/loader/loader_test.go | 91 ++- public/app/app.ts | 3 + .../unified/mocks/server/handlers/plugins.ts | 1 + .../alerting/unified/testSetup/plugins.ts | 1 + .../alerting/unified/utils/rules.test.ts | 1 + .../plugins/components/AppRootPage.test.tsx | 2 + .../plugins/components/AppRootPage.tsx | 3 + .../extensions/ExtensionRegistriesContext.tsx | 18 +- .../app/features/plugins/extensions/errors.ts | 5 + .../plugins/extensions/getPluginExtensions.ts | 2 +- .../registry/AddedComponentsRegistry.test.ts | 1 + .../registry/AddedFunctionsRegistry.test.ts | 677 ++++++++++++++++++ .../registry/AddedFunctionsRegistry.ts | 87 +++ .../registry/AddedLinksRegistry.test.ts | 1 + .../ExposedComponentsRegistry.test.ts | 1 + .../plugins/extensions/registry/setup.ts | 3 + .../plugins/extensions/registry/types.ts | 2 + .../extensions/usePluginComponent.test.tsx | 4 + .../extensions/usePluginComponents.test.tsx | 3 + .../extensions/usePluginExtensions.test.tsx | 2 + .../plugins/extensions/usePluginFunctions.tsx | 82 +++ .../extensions/usePluginLinks.test.tsx | 3 + .../plugins/extensions/utils.test.tsx | 13 + .../plugins/extensions/validators.test.tsx | 4 + .../features/plugins/extensions/validators.ts | 33 + .../app/features/plugins/importPanelPlugin.ts | 1 - public/app/features/plugins/plugin_loader.ts | 12 +- 40 files changed, 1182 insertions(+), 69 deletions(-) create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts create mode 100644 public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts create mode 100644 public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts create mode 100644 public/app/features/plugins/extensions/usePluginFunctions.tsx diff --git a/.betterer.results b/.betterer.results index ca4b89c9605..89b413d6023 100644 --- a/.betterer.results +++ b/.betterer.results @@ -491,6 +491,9 @@ exports[`better eslint`] = { "packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -5602,6 +5605,9 @@ exports[`better eslint`] = { [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [ [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 76195d7d8c0..6aa49febd08 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -549,6 +549,7 @@ export { type PluginExtensionLink, type PluginExtensionComponent, type PluginExtensionConfig, + type PluginExtensionFunction, type PluginExtensionLinkConfig, type PluginExtensionComponentConfig, type PluginExtensionEventHelpers, @@ -559,6 +560,7 @@ export { type PluginExtensionExposedComponentConfig, type PluginExtensionAddedComponentConfig, type PluginExtensionAddedLinkConfig, + type PluginExtensionAddedFunctionConfig, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 3b33452bd03..23e52f913b8 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -9,6 +9,7 @@ import { PluginExtensionExposedComponentConfig, PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig, + PluginExtensionAddedFunctionConfig, } from './pluginExtensions'; /** @@ -60,6 +61,7 @@ export class AppPlugin extends GrafanaPlugin>; @@ -113,6 +115,10 @@ export class AppPlugin extends GrafanaPlugin(linkConfig: PluginExtensionAddedLinkConfig) { this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig); @@ -125,6 +131,12 @@ export class AppPlugin extends GrafanaPlugin(addedFunctionConfig: PluginExtensionAddedFunctionConfig) { + this._addedFunctionConfigs.push(addedFunctionConfig); + + return this; + } + exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) { this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig); diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index ebb3da684f6..8e1be814c96 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -130,6 +130,8 @@ export interface PluginExtensions { // The component extensions that the plugin registers addedComponents: ExtensionInfo[]; + addedFunctions: ExtensionInfo[]; + // The link extensions that the plugin registers addedLinks: ExtensionInfo[]; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index fcbbecf5f41..5b10f80f143 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -14,6 +14,7 @@ import { RawTimeRange, TimeZone } from './time'; export enum PluginExtensionTypes { link = 'link', component = 'component', + function = 'function', } type PluginExtensionBase = { @@ -36,7 +37,12 @@ export type PluginExtensionComponent = PluginExtensionBase & { component: React.ComponentType; }; -export type PluginExtension = PluginExtensionLink | PluginExtensionComponent; +export type PluginExtensionFunction void> = PluginExtensionBase & { + type: PluginExtensionTypes.function; + fn: Signature; +}; + +export type PluginExtension = PluginExtensionLink | PluginExtensionComponent | PluginExtensionFunction; // Objects used for registering extensions (in app plugins) // -------------------------------------------------------- @@ -74,6 +80,17 @@ export type PluginExtensionAddedComponentConfig = PluginExtensionCon */ component: React.ComponentType; }; +export type PluginExtensionAddedFunctionConfig = PluginExtensionConfigBase & { + /** + * The target extension points where the component will be added + */ + targets: string | string[]; + + /** + * The function to be executed + */ + fn: Signature; +}; export type PluginAddedLinksConfigureFunc = (context: Readonly | undefined) => | Partial<{ diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 3e02e0d2863..5e8892c5cc7 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -22,6 +22,8 @@ export { type UsePluginExtensions, type UsePluginExtensionsResult, type UsePluginComponentResult, + type UsePluginFunctionsOptions, + type UsePluginFunctionsResult, } from './pluginExtensions/getPluginExtensions'; export { setPluginExtensionsHook, @@ -33,6 +35,7 @@ export { export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks'; +export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index be151b05858..2f70132d5fe 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -1,4 +1,9 @@ -import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data'; +import type { + PluginExtension, + PluginExtensionLink, + PluginExtensionComponent, + PluginExtensionFunction, +} from '@grafana/data'; import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; @@ -52,6 +57,16 @@ export type UsePluginLinksResult = { links: PluginExtensionLink[]; }; +export type UsePluginFunctionsOptions = { + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type UsePluginFunctionsResult = { + isLoading: boolean; + functions: Array>; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts new file mode 100644 index 00000000000..1eb86b70e14 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts @@ -0,0 +1,20 @@ +import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions'; + +export type UsePluginFunctions = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult; + +let singleton: UsePluginFunctions | undefined; + +export function setPluginFunctionsHook(hook: UsePluginFunctions): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setUsePluginFunctionsHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginFunctions(options: UsePluginFunctionsOptions): UsePluginFunctionsResult { + if (!singleton) { + throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.'); + } + return singleton(options) as UsePluginFunctionsResult; +} diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 9664f824186..18946548c56 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -57,6 +57,7 @@ func TestFinder_Find(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -96,8 +97,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -127,8 +130,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -200,8 +205,10 @@ func TestFinder_Find(t *testing.T) { {Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -238,8 +245,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -269,8 +278,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -300,8 +311,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -340,8 +353,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index d39c9c21303..e5d1b260199 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -106,6 +106,7 @@ func TestLoader_Load(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -201,8 +202,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -249,8 +252,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -304,8 +309,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -398,8 +405,10 @@ func TestLoader_Load(t *testing.T) { {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 6b9b8e05ad2..8440cb37a11 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -63,6 +63,7 @@ type ExtensionsV2 struct { AddedComponents []AddedComponent `json:"addedComponents"` ExposedComponents []ExposedComponent `json:"exposedComponents"` ExtensionPoints []ExtensionPoint `json:"extensionPoints"` + AddedFunctions []AddedFunction `json:"addedFunctions"` } type Extensions ExtensionsV2 @@ -76,6 +77,7 @@ func (e *Extensions) UnmarshalJSON(data []byte) error { e.AddedLinks = extensionsV2.AddedLinks e.ExposedComponents = extensionsV2.ExposedComponents e.ExtensionPoints = extensionsV2.ExtensionPoints + e.AddedFunctions = extensionsV2.AddedFunctions return nil } @@ -123,6 +125,11 @@ type AddedComponent struct { Description string `json:"description"` } +type AddedFunction struct { + Targets []string `json:"targets"` + Title string `json:"title"` +} + type ExposedComponent struct { Id string `json:"id"` Title string `json:"title"` @@ -267,6 +274,7 @@ type PluginMetaDTO struct { Angular AngularMeta `json:"angular"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + Extensions Extensions `json:"extensions"` } type DataSourceDTO struct { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 3239927747b..f815d07c6b1 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -167,6 +167,10 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) { plugin.Extensions.AddedComponents = []AddedComponent{} } + if plugin.Extensions.AddedFunctions == nil { + plugin.Extensions.AddedFunctions = []AddedFunction{} + } + if plugin.Extensions.ExposedComponents == nil { plugin.Extensions.ExposedComponents = []ExposedComponent{} } diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index b3922755603..2a6f85dee07 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -56,6 +56,7 @@ func Test_ReadPluginJSON(t *testing.T) { Extensions: Extensions{ AddedLinks: []AddedLink{}, AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -108,8 +109,10 @@ func Test_ReadPluginJSON(t *testing.T) { Name: "Pie Chart (old)", Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -143,8 +146,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeDataSource, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -188,6 +193,9 @@ func Test_ReadPluginJSON(t *testing.T) { "id": "myorg-extensions-app/component-1/v1" } ], + "addedFunctions": [ + {"targets": ["foo/bar"], "title":"some hook"} + ], "extensionPoints": [ { "title": "Extension point 1", @@ -209,6 +217,7 @@ func Test_ReadPluginJSON(t *testing.T) { {Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}}, }, AddedComponents: []AddedComponent{ + {Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}}, }, ExposedComponents: []ExposedComponent{ @@ -217,6 +226,9 @@ func Test_ReadPluginJSON(t *testing.T) { ExtensionPoints: []ExtensionPoint{ {Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"}, }, + AddedFunctions: []AddedFunction{ + {Targets: []string{"foo/bar"}, Title: "some hook"}, + }, }, Dependencies: Dependencies{ @@ -271,6 +283,7 @@ func Test_ReadPluginJSON(t *testing.T) { AddedComponents: []AddedComponent{ {Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}}, }, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -301,8 +314,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeApp, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -332,8 +347,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeApp, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -371,6 +388,7 @@ func Test_ReadPluginJSON(t *testing.T) { Extensions: Extensions{ AddedLinks: []AddedLink{}, AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index ca6fef68b50..644058909d4 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -105,6 +105,7 @@ func TestLoader_Load(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -200,8 +201,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -248,8 +251,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -309,8 +314,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -423,8 +430,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -504,8 +513,10 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -615,8 +626,10 @@ func TestLoader_Load_CustomSource(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -696,8 +709,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -801,8 +816,10 @@ func TestLoader_Load_RBACReady(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -883,8 +900,10 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { ExposedComponents: []string{}, }}, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -964,8 +983,10 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1060,8 +1081,10 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1272,8 +1295,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1314,8 +1339,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1463,8 +1490,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1512,8 +1541,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/public/app/app.ts b/public/app/app.ts index f4c4fd5d77c..cc3a2e5e5c5 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -40,6 +40,7 @@ import { setChromeHeaderHeightHook, setPluginLinksHook, setCorrelationsService, + setPluginFunctionsHook, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -89,6 +90,7 @@ import { pluginExtensionRegistries } from './features/plugins/extensions/registr import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; +import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions'; import { usePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; @@ -229,6 +231,7 @@ export class GrafanaApp { setPluginLinksHook(usePluginLinks); setPluginComponentHook(usePluginComponent); setPluginComponentsHook(usePluginComponents); + setPluginFunctionsHook(usePluginFunctions); // initialize chrome service const queryParams = locationService.getSearchObject(); diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts index 1d30b05ad50..e0a0142993f 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -24,6 +24,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => { addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '', diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts index dd3fb82ee76..72a94187ef0 100644 --- a/public/app/features/alerting/unified/testSetup/plugins.ts +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -163,6 +163,7 @@ export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfi addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, }; } diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index 4b4036c40d2..63c0cb62f04 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -55,6 +55,7 @@ describe('getRuleOrigin', () => { addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '', diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index 84736aed8de..c9e5ec616be 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo'; import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext'; import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry'; import { getPluginSettings } from '../pluginSettings'; @@ -93,6 +94,7 @@ function renderUnderRouter(page = '') { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; const pagePath = page ? `/${page}` : ''; const route = { diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index f0d6bd5329e..95a280027df 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -29,6 +29,7 @@ import { useAddedLinksRegistry, useAddedComponentsRegistry, useExposedComponentsRegistry, + useAddedFunctionsRegistry, } from '../extensions/ExtensionRegistriesContext'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; @@ -60,6 +61,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { const addedLinksRegistry = useAddedLinksRegistry(); const addedComponentsRegistry = useAddedComponentsRegistry(); const exposedComponentsRegistry = useExposedComponentsRegistry(); + const addedFunctionsRegistry = useAddedFunctionsRegistry(); const location = useLocation(); const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const currentUrl = config.appSubUrl + location.pathname + location.search; @@ -104,6 +106,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { addedLinksRegistry: addedLinksRegistry.readOnly(), addedComponentsRegistry: addedComponentsRegistry.readOnly(), exposedComponentsRegistry: exposedComponentsRegistry.readOnly(), + addedFunctionsRegistry: addedFunctionsRegistry.readOnly(), }} > (undefined); export const AddedComponentsRegistryContext = createContext(undefined); +export const AddedFunctionsRegistryContext = createContext(undefined); export const ExposedComponentsRegistryContext = createContext(undefined); export function useAddedLinksRegistry(): AddedLinksRegistry { @@ -31,6 +33,14 @@ export function useAddedComponentsRegistry(): AddedComponentsRegistry { return context; } +export function useAddedFunctionsRegistry(): AddedFunctionsRegistry { + const context = useContext(AddedFunctionsRegistryContext); + if (!context) { + throw new Error('No `AddedFunctionsRegistry` found.'); + } + return context; +} + export function useExposedComponentsRegistry(): ExposedComponentsRegistry { const context = useContext(ExposedComponentsRegistryContext); if (!context) { @@ -46,9 +56,11 @@ export const ExtensionRegistriesProvider = ({ return ( - - {children} - + + + {children} + + ); diff --git a/public/app/features/plugins/extensions/errors.ts b/public/app/features/plugins/extensions/errors.ts index 39c65df0f8e..cb9d22e7b46 100644 --- a/public/app/features/plugins/extensions/errors.ts +++ b/public/app/features/plugins/extensions/errors.ts @@ -8,6 +8,8 @@ export const TITLE_MISSING = 'Title is missing.'; export const DESCRIPTION_MISSING = 'Description is missing.'; +export const INVALID_EXTENSION_FUNCTION = 'The "fn" argument is invalid, it should be a function.'; + export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.'; export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.'; @@ -33,6 +35,9 @@ export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the titl export const ADDED_LINK_META_INFO_MISSING = 'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.'; +export const ADDED_FUNCTION_META_INFO_MISSING = + 'The extension was not recorded in the plugin.json. Added function extensions must be listed in the section "extensions.addedFunction[]". Currently, this is only required in development but will be enforced also in production builds in the future.'; + export const DESCRIPTION_NOT_MATCHING_META_INFO = 'The "description" doesn\'t match the description recorded in plugin.json.'; diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index a47292c8497..30f5865b259 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,8 +1,8 @@ import { isString } from 'lodash'; import { - type PluginExtension, PluginExtensionTypes, + type PluginExtension, type PluginExtensionLink, type PluginExtensionComponent, } from '@grafana/data'; diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index e82c48dcee0..5a18e222431 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts new file mode 100644 index 00000000000..ae0addc52c7 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts @@ -0,0 +1,677 @@ +import { firstValueFrom } from 'rxjs'; + +import { PluginLoadingStrategy } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { log } from '../logs/log'; +import { resetLogMock } from '../logs/testUtils'; +import { isGrafanaDevMode } from '../utils'; + +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; +import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry'; + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + + // Manually set the dev mode to false + // (to make sure that by default we are testing a production scneario) + isGrafanaDevMode: jest.fn().mockReturnValue(false), +})); + +jest.mock('../logs/log', () => { + const { createLogMock } = jest.requireActual('../logs/testUtils'); + const original = jest.requireActual('../logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + +describe('addedFunctionsRegistry', () => { + const originalApps = config.apps; + const pluginId = 'grafana-basic-app'; + const appPluginConfig = { + id: pluginId, + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedFunctions: [], + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + beforeEach(() => { + resetLogMock(log); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; + }); + + it('should return empty registry when no extensions registered', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + const observable = addedFunctionsRegistry.asObservable(); + const registry = await firstValueFrom(observable); + expect(registry).toEqual({}); + }); + + it('should be possible to register function extensions in the registry', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + + addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn(), + }, + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn(), + }, + ], + }); + + const registry = await addedFunctionsRegistry.getState(); + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: pluginId2, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId2, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: pluginId, + + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should notify subscribers when the registry changes', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: 'another-plugin', + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(3); + + const registry = subscribeCallback.mock.calls[2][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: 'another-plugin', + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should give the last version of the registry for new subscribers', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should not register a function extension if it has an invalid fn function', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + //@ts-ignore + fn: '...', + }, + ], + }); + + expect(log.error).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register a function extension if it has invalid properties (empty title)', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: '', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(log.error).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not be possible to register a function on a read-only registry', async () => { + const pluginId = 'grafana-basic-app'; + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + + expect(() => { + readOnlyRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + }).toThrow(MSG_CANNOT_REGISTER_READ_ONLY); + + const currentState = await readOnlyRegistry.getState(); + expect(Object.keys(currentState)).toHaveLength(0); + }); + + it('should pass down fresh registrations to the read-only version of the registry', async () => { + const pluginId = 'grafana-basic-app'; + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const subscribeCallback = jest.fn(); + let readOnlyState; + + // Should have no extensions registered in the beginning + readOnlyState = await readOnlyRegistry.getState(); + expect(Object.keys(readOnlyState)).toHaveLength(0); + + readOnlyRegistry.asObservable().subscribe(subscribeCallback); + + // Register an extension to the original (writable) registry + registry.register({ + pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // The read-only registry should have received the new extension + readOnlyState = await readOnlyRegistry.getState(); + expect(Object.keys(readOnlyState)).toHaveLength(1); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']); + }); + + it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = []; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(0); + expect(log.error).toHaveBeenCalled(); + }); + + it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + registry.register({ + pluginId: 'grafana', + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => { + // Production mode + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = []; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: ['grafana/dashboard/panel/menu'], + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = [fnConfig]; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts new file mode 100644 index 00000000000..d23fe5e78b2 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts @@ -0,0 +1,87 @@ +import { isFunction } from 'lodash'; +import { ReplaySubject } from 'rxjs'; + +import { PluginExtensionAddedFunctionConfig } from '@grafana/data'; + +import * as errors from '../errors'; +import { isGrafanaDevMode } from '../utils'; +import { isAddedFunctionMetaInfoMissing } from '../validators'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +const logPrefix = 'Could not register function extension. Reason:'; + +export type AddedFunctionsRegistryItem = { + pluginId: string; + title: string; + fn: unknown; + description?: string; +}; + +export class AddedFunctionsRegistry extends Registry { + constructor( + options: { + registrySubject?: ReplaySubject>; + initialState?: RegistryType; + } = {} + ) { + super(options); + } + + mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType { + const { pluginId, configs } = item; + for (const config of configs) { + const configLog = this.logger.child({ + title: config.title, + pluginId, + }); + + if (!config.title) { + configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`); + continue; + } + + if (!isFunction(config.fn)) { + configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`); + continue; + } + + if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) { + continue; + } + + const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; + for (const extensionPointId of extensionPointIds) { + const pointIdLog = configLog.child({ extensionPointId }); + + const result = { + pluginId, + fn: config.fn, + description: config.description, + title: config.title, + extensionPointId, + }; + + pointIdLog.debug('Added function extension successfully registered'); + + if (!(extensionPointId in registry)) { + registry[extensionPointId] = [result]; + } else { + registry[extensionPointId].push(result); + } + } + } + + return registry; + } + + // Returns a read-only version of the registry. + readOnly() { + return new AddedFunctionsRegistry({ + registrySubject: this.registrySubject, + }); + } +} diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index 4d5ab5c084f..d3586240276 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts index 0a7036894d1..863c89f7b40 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts @@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index 6c2fd1e6a5f..91b7badc4eb 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -1,6 +1,7 @@ import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations'; import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './types'; @@ -8,10 +9,12 @@ import { PluginExtensionRegistries } from './types'; export const addedComponentsRegistry = new AddedComponentsRegistry(); export const exposedComponentsRegistry = new ExposedComponentsRegistry(); export const addedLinksRegistry = new AddedLinksRegistry(); +export const addedFunctionsRegistry = new AddedFunctionsRegistry(); export const pluginExtensionRegistries: PluginExtensionRegistries = { addedComponentsRegistry, exposedComponentsRegistry, addedLinksRegistry, + addedFunctionsRegistry, }; // Registering core extensions diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts index 115e859b7d9..1927bf31b75 100644 --- a/public/app/features/plugins/extensions/registry/types.ts +++ b/public/app/features/plugins/extensions/registry/types.ts @@ -1,9 +1,11 @@ import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; export type PluginExtensionRegistries = { addedComponentsRegistry: AddedComponentsRegistry; exposedComponentsRegistry: ExposedComponentsRegistry; + addedFunctionsRegistry: AddedFunctionsRegistry; addedLinksRegistry: AddedLinksRegistry; }; diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 6385c022027..b2a3d3d3435 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -7,6 +7,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -78,6 +79,7 @@ describe('usePluginComponent()', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], // This is necessary, so we can register exposed components to the registry during the tests // (Otherwise the registry would reject it in the imitated production mode) exposedComponents: [exposedComponentConfig], @@ -90,6 +92,7 @@ describe('usePluginComponent()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); jest.mocked(isGrafanaDevMode).mockReturnValue(false); @@ -122,6 +125,7 @@ describe('usePluginComponent()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 75adda33f75..fcb729fdd64 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -60,6 +61,7 @@ describe('usePluginComponents()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(wrapWithPluginContext).mockClear(); @@ -89,6 +91,7 @@ describe('usePluginComponents()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 739906dd815..479e5cd4c6c 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -19,6 +20,7 @@ describe('usePluginExtensions()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); }); diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx new file mode 100644 index 00000000000..68acee76221 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data'; +import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; + +import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; +import * as errors from './errors'; +import { log } from './logs/log'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; +import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; + +// Returns an array of component extensions for the given extension point +export function usePluginFunctions({ + limitPerPlugin, + extensionPointId, +}: UsePluginFunctionsOptions): UsePluginFunctionsResult { + const registry = useAddedFunctionsRegistry(); + const registryState = useObservable(registry.asObservable()); + const pluginContext = usePluginContext(); + const deps = getExtensionPointPluginDependencies(extensionPointId); + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); + + return useMemo(() => { + // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. + const enableRestrictions = isGrafanaDevMode() && pluginContext; + const results: Array> = []; + const extensionsByPlugin: Record = {}; + const pluginId = pluginContext?.meta.id ?? ''; + const pointLog = log.child({ + pluginId, + extensionPointId, + }); + if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { + pointLog.error(errors.INVALID_EXTENSION_POINT_ID); + } + + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { + pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); + return { + isLoading: false, + functions: [], + }; + } + + if (isLoadingAppPlugins) { + return { + isLoading: true, + functions: [], + }; + } + + for (const registryItem of registryState?.[extensionPointId] ?? []) { + const { pluginId } = registryItem; + + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { + continue; + } + + if (extensionsByPlugin[pluginId] === undefined) { + extensionsByPlugin[pluginId] = 0; + } + + results.push({ + id: generateExtensionId(pluginId, extensionPointId, registryItem.title), + type: PluginExtensionTypes.function, + title: registryItem.title, + description: registryItem.description ?? '', + pluginId: pluginId, + fn: registryItem.fn as Signature, + }); + extensionsByPlugin[pluginId] += 1; + } + + return { + isLoading: false, + functions: results, + }; + }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); +} diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index 9186ca0ddf6..f9fb623f42a 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -57,6 +58,7 @@ describe('usePluginLinks()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; resetLogMock(log); @@ -85,6 +87,7 @@ describe('usePluginLinks()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 32ad6215875..0e2d98f44b3 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -475,6 +475,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, @@ -553,6 +554,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, @@ -584,6 +586,7 @@ describe('Plugin Extensions / Utils', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-third-app': { @@ -623,6 +626,7 @@ describe('Plugin Extensions / Utils', () => { ], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }, }; @@ -679,6 +683,7 @@ describe('Plugin Extensions / Utils', () => { ], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -705,6 +710,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -726,6 +732,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-sixth-app': { @@ -763,6 +770,7 @@ describe('Plugin Extensions / Utils', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; @@ -791,6 +799,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-third-app': { @@ -825,6 +834,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -850,6 +860,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -871,6 +882,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, }; @@ -902,6 +914,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index 12b146bbf5c..f083ff738ea 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -271,6 +271,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const extensionConfig = { @@ -387,6 +388,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const extensionConfig = { @@ -503,6 +505,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const exposedComponentConfig = { @@ -688,6 +691,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index b1dbd5e9af8..cbdaf81e935 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -5,6 +5,7 @@ import type { PluginContextType, PluginExtensionAddedComponentConfig, PluginExtensionExposedComponentConfig, + PluginExtensionAddedFunctionConfig, } from '@grafana/data'; import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions'; import { config, isPluginExtensionLink } from '@grafana/runtime'; @@ -160,6 +161,38 @@ export const isAddedLinkMetaInfoMissing = ( return false; }; +export const isAddedFunctionMetaInfoMissing = ( + pluginId: string, + metaInfo: PluginExtensionAddedFunctionConfig, + log: ExtensionsLog +) => { + const logPrefix = 'Could not register function extension. Reason:'; + const app = config.apps[pluginId]; + const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null; + + if (!app) { + log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); + return true; + } + + if (!pluginJsonMetaInfo) { + log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`); + return true; + } + + const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; + if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`); + return true; + } + + if (pluginJsonMetaInfo.description !== metaInfo.description) { + log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); + } + + return false; +}; + export const isAddedComponentMetaInfoMissing = ( pluginId: string, metaInfo: PluginExtensionAddedComponentConfig, diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 45732de7fb1..f6d9c1ac2a6 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise { if (!plugin.panel && plugin.angularPanelCtrl) { plugin.panel = getAngularPanelReactWrapper(plugin); } - return plugin; }) .catch((err) => { diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index aa20ae5881f..98915c40f6d 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -13,7 +13,12 @@ import { DataQuery } from '@grafana/schema'; import { GenericDataSourcePlugin } from '../datasources/types'; import builtInPlugins from './built_in_plugins'; -import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup'; +import { + addedComponentsRegistry, + addedFunctionsRegistry, + addedLinksRegistry, + exposedComponentsRegistry, +} from './extensions/registry/setup'; import { getPluginFromCache, registerPluginInCache } from './loader/cache'; // SystemJS has to be imported before the sharedDependenciesMap import { SystemJS } from './loader/systemjs'; @@ -153,7 +158,6 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise, @@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise { pluginId, configs: plugin.addedLinkConfigs || [], }); + addedFunctionsRegistry.register({ + pluginId, + configs: plugin.addedFunctionConfigs || [], + }); importedAppPlugins[pluginId] = plugin; From 158eebe45b7f35e7965542fa81dceaf4a5298024 Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Thu, 13 Feb 2025 10:33:26 +0100 Subject: [PATCH 26/78] Provisioning: Encrypt GitHub token (#100515) * feat: new secrets impl * fix: send interval as a number * feat: use UUIDs for webhook secrets * Provisioning: Encrypt GitHub token * refactor: have a wrapping interface * refactor: simplify decryption * refactor: move encryption to mutation hook * feat: use omitempty again * feat: always decrypt * chore: update comment to reality --- pkg/apis/provisioning/v0alpha1/types.go | 5 +- .../provisioning/controller/repository.go | 22 +++++--- pkg/registry/apis/provisioning/register.go | 29 +++++++++- .../apis/provisioning/repository/github.go | 55 ++++++++++--------- .../apis/provisioning/secrets/secret.go | 33 +++++++---- pkg/services/secrets/migrator/migrator.go | 1 + .../app/features/provisioning/ConfigForm.tsx | 2 +- 7 files changed, 94 insertions(+), 53 deletions(-) diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 203c59d4dd3..a245246335f 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -51,10 +51,13 @@ type GitHubRepositoryConfig struct { // By default, this is the main branch. Branch string `json:"branch,omitempty"` - // Token for accessing the repository. + // Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again. // TODO: this should be part of secrets and a simple reference. Token string `json:"token,omitempty"` + // Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted. + EncryptedToken []byte `json:"encryptedToken,omitempty"` + // Workflow allowed for changes to the repository. // The order is relevant for defining the precedence of the workflows. // Possible values: pull-request, branch, push. diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 987db9fb99a..2cbbd48e5af 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" ) type RepoGetter interface { @@ -57,6 +58,7 @@ type RepositoryController struct { repoSynced cache.InformerSynced parsers *resources.ParserFactory logger logging.Logger + secrets secrets.Service jobs jobs.JobQueue finalizer *finalizer @@ -81,6 +83,7 @@ func NewRepositoryController( parsers *resources.ParserFactory, tester RepositoryTester, jobs jobs.JobQueue, + secrets secrets.Service, ) (*RepositoryController, error) { rc := &RepositoryController{ client: provisioningClient, @@ -99,9 +102,10 @@ func NewRepositoryController( lister: resourceLister, client: parsers.Client, }, - tester: tester, - jobs: jobs, - logger: logging.DefaultLogger.With("logger", loggerName), + tester: tester, + jobs: jobs, + logger: logging.DefaultLogger.With("logger", loggerName), + secrets: secrets, } _, err := repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -229,6 +233,12 @@ func (rc *RepositoryController) process(item *queueItem) error { return err } + ctx, _, err := identity.WithProvisioningIdentitiy(context.Background(), namespace) + if err != nil { + return err + } + logger = logger.WithContext(ctx) + healthAge := time.Since(time.UnixMilli(obj.Status.Health.Checked)) syncAge := time.Since(time.UnixMilli(obj.Status.Sync.Finished)) syncInterval := time.Duration(obj.Spec.Sync.IntervalSeconds) * time.Second @@ -244,12 +254,6 @@ func (rc *RepositoryController) process(item *queueItem) error { logger.Info("conditions met", "status", obj.Status, "generation", obj.Generation, "deletion_timestamp", obj.DeletionTimestamp, "sync_spec", obj.Spec.Sync) } - ctx, _, err := identity.WithProvisioningIdentitiy(context.Background(), namespace) - if err != nil { - return err - } - logger = logger.WithContext(ctx) - repo, err := rc.repoGetter.AsRepository(ctx, obj) if err != nil { return fmt.Errorf("unable to create repository from configuration: %w", err) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 3a95640f7b9..28003b9cb3b 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -44,6 +44,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/rendering" + grafanasecrets "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/blob" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -77,6 +78,7 @@ type APIBuilder struct { tester *RepositoryTester resourceLister resources.ResourceLister repositoryLister listers.RepositoryLister + secrets secrets.Service } // NewAPIBuilder creates an API builder. @@ -93,6 +95,7 @@ func NewAPIBuilder( clonedir string, // where repo clones are managed configProvider apiserver.RestConfigProvider, ghFactory github.ClientFactory, + secrets secrets.Service, ) *APIBuilder { clientFactory := resources.NewFactory(configProvider) return &APIBuilder{ @@ -109,6 +112,7 @@ func NewAPIBuilder( clonedir: clonedir, resourceLister: resources.NewResourceLister(index), blobstore: blobstore, + secrets: secrets, } } @@ -124,6 +128,8 @@ func RegisterAPIService( client resource.ResourceClient, // implements resource.RepositoryClient configProvider apiserver.RestConfigProvider, ghFactory github.ClientFactory, + // FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service! + secretssvc grafanasecrets.Service, ) (*APIBuilder, error) { if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { @@ -147,7 +153,7 @@ func RegisterAPIService( builder := NewAPIBuilder(folderResolver, urlProvider, cfg.SecretKey, features, render, client, store, filepath.Join(cfg.DataPath, "clone"), // where repositories are cloned (temporarialy for now) - configProvider, ghFactory) + configProvider, ghFactory, secrets.NewSingleTenant(secretssvc)) apiregistration.RegisterAPI(builder) return builder, nil } @@ -311,8 +317,7 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor gvr.Resource, r.GetName(), ) - secretsSvc := secrets.NewService(b.webhookSecretKey) - return repository.NewGitHub(ctx, r, b.ghFactory, secretsSvc, webhookURL), nil + return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL) case provisioning.S3RepositoryType: return repository.NewS3(r), nil default: @@ -366,6 +371,10 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis } } + if err := b.encryptSecrets(ctx, r); err != nil { + return fmt.Errorf("failed to encrypt secrets: %w", err) + } + return nil } @@ -475,6 +484,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.parsers, &repository.Tester{}, b.jobs, + b.secrets, ) if err != nil { return err @@ -761,3 +771,16 @@ spec: return oas, nil } + +func (b *APIBuilder) encryptSecrets(ctx context.Context, repo *provisioning.Repository) error { + var err error + if repo.Spec.GitHub != nil && + repo.Spec.GitHub.Token != "" { + repo.Spec.GitHub.EncryptedToken, err = b.secrets.Encrypt(ctx, []byte(repo.Spec.GitHub.Token)) + if err != nil { + return err + } + repo.Spec.GitHub.Token = "" + } + return nil +} diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 850e125e276..38392403c0d 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -17,22 +17,20 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" + "github.com/google/uuid" "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" ) var subscribedEvents = []string{"push", "pull_request"} -type SecretsService interface { - Encrypt(ctx context.Context, data string) (string, error) -} - // Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included. type githubRepository struct { config *provisioning.Repository gh pgh.Client // assumes github.com base URL - secrets SecretsService + secrets secrets.Service webhookURL string owner string @@ -45,18 +43,22 @@ func NewGitHub( ctx context.Context, config *provisioning.Repository, factory pgh.ClientFactory, - secrets SecretsService, + secrets secrets.Service, webhookURL string, -) *githubRepository { +) (*githubRepository, error) { owner, repo, _ := parseOwnerRepo(config.Spec.GitHub.URL) + decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken) + if err != nil { + return nil, err + } return &githubRepository{ config: config, - gh: factory.New(ctx, config.Spec.GitHub.Token), // TODO -- base from URL + gh: factory.New(ctx, string(decrypted)), // TODO -- base from URL secrets: secrets, webhookURL: webhookURL, owner: owner, repo: repo, - } + }, nil } func (r *githubRepository) Config() *provisioning.Repository { @@ -86,7 +88,8 @@ func (r *githubRepository) Validate() (list field.ErrorList) { if !isValidGitBranchName(gh.Branch) { list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name")) } - if gh.Token == "" { + // TODO: Use two fields for token + if gh.Token == "" && len(gh.EncryptedToken) == 0 { list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required")) } @@ -741,14 +744,14 @@ func (r *githubRepository) CommentPullRequestFile(ctx context.Context, prNumber } func (r *githubRepository) createWebhook(ctx context.Context) (pgh.WebhookConfig, error) { - secret, err := r.secrets.Encrypt(ctx, r.config.Spec.GitHub.Token) + secret, err := uuid.NewRandom() if err != nil { - return pgh.WebhookConfig{}, fmt.Errorf("encrypt webhook secret: %w", err) + return pgh.WebhookConfig{}, fmt.Errorf("could not generate secret: %w", err) } cfg := pgh.WebhookConfig{ URL: r.webhookURL, - Secret: secret, + Secret: secret.String(), ContentType: "json", Events: subscribedEvents, Active: true, @@ -759,6 +762,9 @@ func (r *githubRepository) createWebhook(ctx context.Context) (pgh.WebhookConfig return pgh.WebhookConfig{}, err } + // HACK: GitHub does not return the secret, so we need to update it manually + hook.Secret = cfg.Secret + logging.FromContext(ctx).Info("webhook created", "url", cfg.URL, "id", hook.ID) return hook, nil } @@ -786,19 +792,10 @@ func (r *githubRepository) updateWebhook(ctx context.Context) (pgh.WebhookConfig return pgh.WebhookConfig{}, false, fmt.Errorf("get webhook: %w", err) } + hook.Secret = r.config.Status.Webhook.Secret // we always random gen this, so don't use it for mustUpdate below. + var mustUpdate bool - secret, err := r.secrets.Encrypt(ctx, r.config.Spec.GitHub.Token) - if err != nil { - return pgh.WebhookConfig{}, false, fmt.Errorf("encrypt webhook secret: %w", err) - } - - // Compare with status secret as we cannot get the screen from the webhook - if secret != r.config.Status.Webhook.Secret { - mustUpdate = true - hook.Secret = r.config.Status.Webhook.Secret - } - if hook.URL != r.config.Status.Webhook.URL { mustUpdate = true hook.URL = r.webhookURL @@ -813,13 +810,17 @@ func (r *githubRepository) updateWebhook(ctx context.Context) (pgh.WebhookConfig return hook, false, nil } + // Something has changed in the webhook. Let's rotate the secret as well, so as to ensure we end up with a 100% correct webhook. + secret, err := uuid.NewRandom() + if err != nil { + return pgh.WebhookConfig{}, false, fmt.Errorf("could not generate secret: %w", err) + } + hook.Secret = secret.String() + if err := r.gh.EditWebhook(ctx, r.owner, r.repo, hook); err != nil { return pgh.WebhookConfig{}, false, fmt.Errorf("edit webhook: %w", err) } - // HACK: GitHub does not return the secret, so we need to update it manually - hook.Secret = secret - return hook, true, nil } diff --git a/pkg/registry/apis/provisioning/secrets/secret.go b/pkg/registry/apis/provisioning/secrets/secret.go index 3caa141f04a..b7699ab057e 100644 --- a/pkg/registry/apis/provisioning/secrets/secret.go +++ b/pkg/registry/apis/provisioning/secrets/secret.go @@ -2,25 +2,34 @@ package secrets import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" + + "github.com/grafana/grafana/pkg/services/secrets" ) +// A secrets encryption service. It only operates on values, no names or similar. +// It is likely we will need to change this when the multi-tenant service comes around. +// // FIXME: this is a temporary service/package until we can make use of // the new secrets service in app platform -type Service struct { - encryptionKey []byte +type Service interface { + Encrypt(ctx context.Context, data []byte) ([]byte, error) + Decrypt(ctx context.Context, data []byte) ([]byte, error) } -func NewService(encryptionKey string) *Service { - return &Service{encryptionKey: []byte(encryptionKey)} +var _ Service = (*singleTenant)(nil) + +type singleTenant struct { + inner secrets.Service } -func (s *Service) Encrypt(ctx context.Context, data string) (string, error) { - h := hmac.New(sha256.New, s.encryptionKey) - h.Write([]byte(data)) - hashed := h.Sum(nil) +func NewSingleTenant(svc secrets.Service) *singleTenant { + return &singleTenant{svc} +} - return hex.EncodeToString(hashed), nil +func (s *singleTenant) Encrypt(ctx context.Context, data []byte) ([]byte, error) { + return s.inner.Encrypt(ctx, data, secrets.WithoutScope()) +} + +func (s *singleTenant) Decrypt(ctx context.Context, data []byte) ([]byte, error) { + return s.inner.Decrypt(ctx, data) } diff --git a/pkg/services/secrets/migrator/migrator.go b/pkg/services/secrets/migrator/migrator.go index 3581c2afd99..d0dd383a62d 100644 --- a/pkg/services/secrets/migrator/migrator.go +++ b/pkg/services/secrets/migrator/migrator.go @@ -51,6 +51,7 @@ func ProvideSecretsMigrator( b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "refresh_token"}, encoding: base64.StdEncoding}, b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "session_id"}, encoding: base64.StdEncoding}, b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "name_id"}, encoding: base64.StdEncoding}, + // FIXME: Rotate provisioning secrets } return &SecretsMigrator{ diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx index 00d8b338ba0..feee5b851aa 100644 --- a/public/app/features/provisioning/ConfigForm.tsx +++ b/public/app/features/provisioning/ConfigForm.tsx @@ -228,7 +228,7 @@ export function ConfigForm({ data }: ConfigFormProps) { /> - +

      From 5a6d2f2e49b8d7c3cb2032c3fe5e88eeed28d524 Mon Sep 17 00:00:00 2001 From: Misi Date: Thu, 13 Feb 2025 10:46:19 +0100 Subject: [PATCH 27/78] Auth: Add early return if `auth_token` is in the URL for JWT auth (#100539) * Add early return * Update public/app/app.ts Co-authored-by: Victor Cinaglia --------- Co-authored-by: Victor Cinaglia --- public/app/app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/app.ts b/public/app/app.ts index cc3a2e5e5c5..86b2386dc6c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -409,6 +409,7 @@ function handleRedirectTo(): void { if (queryParams.has('auth_token')) { // URL Login should not be redirected window.sessionStorage.removeItem(RedirectToUrlKey); + return; } if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') { From 5c8eaa2abf826316b67e370f2e99bf2206002500 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 13 Feb 2025 12:58:25 +0300 Subject: [PATCH 28/78] remove unused change --- pkg/api/folder_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 1f6045d4851..a464727a31b 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -15,7 +15,6 @@ import ( clientrest "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/apimachinery/identity" folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -528,10 +527,6 @@ func (m mockClientConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext } } -func (m mockClientConfigProvider) GetRestConfigForBackgroundWorker(requester func() identity.Requester) *clientrest.Config { - return nil -} - func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {} // for now, test only the general folder From 293f514854294555670e76f910ed63c7c5514a24 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 13 Feb 2025 11:58:28 +0200 Subject: [PATCH 29/78] Dashboard: Fix removing row repeats having indexes ending with 0 (#100487) --- .../RowRepeaterBehavior.test.tsx | 45 +++++++++++++++++++ .../RowItemRepeaterBehavior.test.tsx | 45 +++++++++++++++++++ .../dashboard-scene/utils/clone.test.ts | 14 ++++++ .../features/dashboard-scene/utils/clone.ts | 2 +- 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx index 2e728f25a39..843f58b5640 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx @@ -160,6 +160,51 @@ describe('RowRepeaterBehavior', () => { }); }); + describe('Given scene with variable with 15 values', () => { + let scene: DashboardScene, grid: SceneGridLayout; + let gridStateUpdates: unknown[]; + + beforeEach(async () => { + ({ scene, grid } = buildScene({ variableQueryTime: 0 }, [ + { label: 'A', value: 'A1' }, + { label: 'B', value: 'B1' }, + { label: 'C', value: 'C1' }, + { label: 'D', value: 'D1' }, + { label: 'E', value: 'E1' }, + { label: 'F', value: 'F1' }, + { label: 'G', value: 'G1' }, + { label: 'H', value: 'H1' }, + { label: 'I', value: 'I1' }, + { label: 'J', value: 'J1' }, + { label: 'K', value: 'K1' }, + { label: 'L', value: 'L1' }, + { label: 'M', value: 'M1' }, + { label: 'N', value: 'N1' }, + { label: 'O', value: 'O1' }, + ])); + + gridStateUpdates = []; + grid.subscribeToState((state) => gridStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + }); + + it('Should handle second repeat cycle and update remove old repeats', async () => { + // should have 15 repeated rows (and the panel above + the row at the bottom) + expect(grid.state.children.length).toBe(17); + + // trigger another repeat cycle by changing the variable + const variable = scene.state.$variables!.state.variables[0] as TestVariable; + variable.changeValueTo(['B1', 'C1']); + + await new Promise((r) => setTimeout(r, 1)); + + // should now only have 2 repeated rows (and the panel above + the row at the bottom) + expect(grid.state.children.length).toBe(4); + }); + }); + describe('Given scene empty row', () => { let scene: DashboardScene; let grid: SceneGridLayout; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx index d152fed3a64..ec5b4710089 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx @@ -104,6 +104,51 @@ describe('RowItemRepeaterBehavior', () => { }); }); + describe('Given scene with variable with 15 values', () => { + let scene: DashboardScene, layout: RowsLayoutManager; + let layoutStateUpdates: unknown[]; + + beforeEach(async () => { + ({ scene, layout } = buildScene({ variableQueryTime: 0 }, [ + { label: 'A', value: 'A1' }, + { label: 'B', value: 'B1' }, + { label: 'C', value: 'C1' }, + { label: 'D', value: 'D1' }, + { label: 'E', value: 'E1' }, + { label: 'F', value: 'F1' }, + { label: 'G', value: 'G1' }, + { label: 'H', value: 'H1' }, + { label: 'I', value: 'I1' }, + { label: 'J', value: 'J1' }, + { label: 'K', value: 'K1' }, + { label: 'L', value: 'L1' }, + { label: 'M', value: 'M1' }, + { label: 'N', value: 'N1' }, + { label: 'O', value: 'O1' }, + ])); + + layoutStateUpdates = []; + layout.subscribeToState((state) => layoutStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + }); + + it('Should handle second repeat cycle and update remove old repeats', async () => { + // should have 15 repeated rows (and the panel above) + expect(layout.state.rows.length).toBe(16); + + // trigger another repeat cycle by changing the variable + const variable = scene.state.$variables!.state.variables[0] as TestVariable; + variable.changeValueTo(['B1', 'C1']); + + await new Promise((r) => setTimeout(r, 1)); + + // should now only have 2 repeated rows (and the panel above) + expect(layout.state.rows.length).toBe(3); + }); + }); + describe('Given a scene with empty variable', () => { it('Should preserve repeat row', async () => { const { scene, layout } = buildScene({ variableQueryTime: 0 }, []); diff --git a/public/app/features/dashboard-scene/utils/clone.test.ts b/public/app/features/dashboard-scene/utils/clone.test.ts index 28441c4dbe1..58dcef6fb38 100644 --- a/public/app/features/dashboard-scene/utils/clone.test.ts +++ b/public/app/features/dashboard-scene/utils/clone.test.ts @@ -48,6 +48,20 @@ describe('clone', () => { expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false); expect(isClonedKey('row-clone-1/panel')).toBe(false); }); + + it('should properly handle indexes containing 0', () => { + expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-0')).toBe(false); + expect(isClonedKey('row-clone-0/panel-clone-0')).toBe(false); + expect(isClonedKey('panel-clone-0')).toBe(false); + + expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-101')).toBe(true); + expect(isClonedKey('row-clone-0/panel-clone-101')).toBe(true); + expect(isClonedKey('panel-clone-1010')).toBe(true); + + expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-10')).toBe(true); + expect(isClonedKey('row-clone-0/panel-clone-100')).toBe(true); + expect(isClonedKey('panel-clone-1000')).toBe(true); + }); }); describe('isClonedKeyOf', () => { diff --git a/public/app/features/dashboard-scene/utils/clone.ts b/public/app/features/dashboard-scene/utils/clone.ts index 4e5d2b79fab..05d9459e0e2 100644 --- a/public/app/features/dashboard-scene/utils/clone.ts +++ b/public/app/features/dashboard-scene/utils/clone.ts @@ -1,7 +1,7 @@ const CLONE_KEY = '-clone-'; const CLONE_SEPARATOR = '/'; -const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9]+$`); +const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9][0-9]*$`); const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`); /** From 95ee93a0d8d3df333ffc64742c7b525a50b640a1 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Thu, 13 Feb 2025 11:07:24 +0100 Subject: [PATCH 30/78] Plugins: Improve plugin details UX for core plugins (#99830) --- public/app/features/plugins/admin/api.ts | 1 + .../components/PluginDetailsPage.test.tsx | 25 +++++++++++++++++++ .../plugins/admin/components/VersionList.tsx | 2 +- .../admin/hooks/usePluginDetailsTabs.tsx | 6 +++-- .../plugins/admin/hooks/usePluginInfo.tsx | 5 +++- public/app/features/plugins/admin/types.ts | 1 + 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index ef9411bea9e..36d49c2dbd7 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -94,6 +94,7 @@ async function getPluginVersions(id: string, isPublished: boolean): Promise ({ version: v.version, createdAt: v.createdAt, + updatedAt: v.updatedAt, isCompatible: v.isCompatible, grafanaDependency: v.grafanaDependency, angularDetected: v.angularDetected, diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx index 4f1bde85393..ad97440a183 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -57,6 +57,7 @@ const plugin: CatalogPlugin = { ], grafanaDependency: '>=9.0.0', statusContext: 'stable', + changelog: 'Test changelog', }, angularDetected: false, isFullyInstalled: true, @@ -154,4 +155,28 @@ describe('PluginDetailsPage', () => { render(); expect(screen.getByRole('tab', { name: 'Data source connections' })).toBeVisible(); }); + + it('should not show version and changelog tabs when plugin is core', () => { + mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true }); + render(); + expect(screen.queryByRole('tab', { name: 'Version history' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Changelog' })).not.toBeInTheDocument(); + }); + + it('should not show last version in plugin details panel when plugin is core', () => { + config.featureToggles.pluginsDetailsRightPanel = true; + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query !== '(max-width: 600px)', + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true, latestVersion: '1.2.0' }); + + render(); + expect(screen.queryByText('Latest Version:')).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx index aaf98fd183b..2e79b0d43c1 100644 --- a/public/app/features/plugins/admin/components/VersionList.tsx +++ b/public/app/features/plugins/admin/components/VersionList.tsx @@ -96,7 +96,7 @@ export const VersionList = ({ pluginId, versions = [], installedVersion, disable {/* Last updated */} - {dateTimeFormatTimeAgo(version.createdAt)} + {dateTimeFormatTimeAgo(version.updatedAt || version.createdAt)} {/* Dependency */} {version.grafanaDependency || 'N/A'} diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index e9c50269d15..d9f7c2e1375 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -42,7 +42,8 @@ export const usePluginDetailsTabs = ( const navModelChildren = useMemo(() => { const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin); const navModelChildren: NavModelItem[] = []; - if (isPublished) { + // currently the versions available of core plugins are not consistent + if (isPublished && !plugin?.isCore) { navModelChildren.push({ text: PluginTabLabels.VERSIONS, id: PluginTabIds.VERSIONS, @@ -51,7 +52,8 @@ export const usePluginDetailsTabs = ( active: PluginTabIds.VERSIONS === currentPageId, }); } - if (isPublished && plugin?.details?.changelog) { + // currently there is not changelog available for core plugins + if (isPublished && plugin?.details?.changelog && !plugin.isCore) { navModelChildren.push({ text: PluginTabLabels.CHANGELOG, id: PluginTabIds.CHANGELOG, diff --git a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx index 1bb1334e1ef..2c9124f1860 100644 --- a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx @@ -53,7 +53,10 @@ export const usePluginInfo = (plugin?: CatalogPlugin): PageInfoItem[] => { latestVersionValue = latestVersion; } - addInfo('latestVersion', latestVersionValue); + // latest versions of core plugins are not consistent + if (!plugin.isCore) { + addInfo('latestVersion', latestVersionValue); + } } if (Boolean(plugin.orgName)) { diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 10e2636b2ef..031f6c1543c 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -216,6 +216,7 @@ export interface Build { export interface Version { version: string; createdAt: string; + updatedAt?: string; isCompatible: boolean; grafanaDependency: string | null; angularDetected?: boolean; From 0b4c622df8e3796d02b714f7d261215e23178eb6 Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:27:57 +0100 Subject: [PATCH 31/78] AuthN: Refetch user on "ErrUserAlreadyExists" (#100346) * AuthN: Refetch user on "ErrUserAlreadyExists" --- .../authn/authnimpl/sync/user_sync.go | 41 +++++++++++-------- .../authn/authnimpl/sync/user_sync_test.go | 30 ++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index 23e8d579b4d..22ec16fa51a 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -86,29 +86,38 @@ func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *auth } // Does user exist in the database? - usr, userAuth, errUserInDB := s.getUser(ctx, id) - if errUserInDB != nil && !errors.Is(errUserInDB, user.ErrUserNotFound) { - s.log.FromContext(ctx).Error("Failed to fetch user", "error", errUserInDB, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) + usr, userAuth, err := s.getUser(ctx, id) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + s.log.FromContext(ctx).Error("Failed to fetch user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to retrieve user") } - if errors.Is(errUserInDB, user.ErrUserNotFound) { + if errors.Is(err, user.ErrUserNotFound) { if !id.ClientParams.AllowSignUp { s.log.FromContext(ctx).Warn("Failed to create user, signup is not allowed for module", "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errUserSignupDisabled.Errorf("%w", errSignupNotAllowed) } // create user - var errCreate error - usr, errCreate = s.createUser(ctx, id) - if errCreate != nil { - s.log.FromContext(ctx).Error("Failed to create user", "error", errCreate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) - return errSyncUserInternal.Errorf("unable to create user: %w", errCreate) + usr, err = s.createUser(ctx, id) + + // There is a possibility for a race condition when creating a user. Most clients will probably not hit this + // case but others will. The one we have seen this issue for is auth proxy. First time a new user loads grafana + // several requests can get "user.ErrUserNotFound" at the same time but only one of the request will be allowed + // to actually create the user, resulting in all other requests getting "user.ErrUserAlreadyExists". So we can + // just try to fetch the user one more to make the other request work. + if errors.Is(err, user.ErrUserAlreadyExists) { + usr, _, err = s.getUser(ctx, id) + } + + if err != nil { + s.log.FromContext(ctx).Error("Failed to create user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) + return errSyncUserInternal.Errorf("unable to create user: %w", err) } } else { // update user - if errUpdate := s.updateUserAttributes(ctx, usr, id, userAuth); errUpdate != nil { - s.log.FromContext(ctx).Error("Failed to update user", "error", errUpdate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) + if err := s.updateUserAttributes(ctx, usr, id, userAuth); err != nil { + s.log.FromContext(ctx).Error("Failed to update user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to update user") } } @@ -311,6 +320,7 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.User, error) { ctx, span := s.tracer.Start(ctx, "user.sync.createUser") defer span.End() + // FIXME(jguer): this should be done in the user service // quota check: we can have quotas on both global and org level // therefore we need to query check quota for both user and org services @@ -330,19 +340,18 @@ func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.Us isAdmin = *id.IsGrafanaAdmin } - usr, errCreateUser := s.userService.Create(ctx, &user.CreateUserCommand{ + usr, err := s.userService.Create(ctx, &user.CreateUserCommand{ Login: id.Login, Email: id.Email, Name: id.Name, IsAdmin: isAdmin, SkipOrgSetup: len(id.OrgRoles) > 0, }) - if errCreateUser != nil { - return nil, errCreateUser + if err != nil { + return nil, err } - err := s.upsertAuthConnection(ctx, usr.ID, id, true) - if err != nil { + if err := s.upsertAuthConnection(ctx, usr.ID, id, true); err != nil { return nil, err } diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index dc6cab243b7..8999a4b6979 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" claims "github.com/grafana/authlib/types" @@ -451,6 +452,35 @@ func TestUserSync_SyncUserHook(t *testing.T) { } } +func TestUserSync_SyncUserRetryFetch(t *testing.T) { + userSrv := usertest.NewMockService(t) + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once() + userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once() + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{ID: 1}, nil).Once() + + s := ProvideUserSync( + userSrv, + authinfoimpl.ProvideOSSUserProtectionService(), + &authinfotest.FakeService{}, + "atest.FakeQuotaService{}, + tracing.NewNoopTracerService(), + featuremgmt.WithFeatures(), + ) + + email := "test@test.com" + + err := s.SyncUserHook(context.Background(), &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + LookUpParams: login.UserLookupParams{ + Email: &email, + }, + }, + }, nil) + require.NoError(t, err) +} + func TestUserSync_FetchSyncedUserHook(t *testing.T) { type testCase struct { desc string From 6db155649c255fc673a9906476bebbf4813ab48f Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Thu, 13 Feb 2025 11:31:57 +0100 Subject: [PATCH 32/78] Plugins: Custom links for plugin details page (#97186) * Custom links with repository link, licence link, docs link and raise an issue link * run translation command * delete console log * delete console log * fix frontend tests * change UI with a new design * remove license, documentation, repository url calculation logic from grafana * remove unsused function from helpers * change repo icons and raise an issue icon * fix the build * remove logic for raiseAnIssueUrl * fix the build * fix lint * Delete Links title in the box of links --------- Co-authored-by: Timur Olzhabayev --- .betterer.results | 3 +- public/app/features/plugins/admin/api.ts | 2 + .../components/PluginDetailsPanel.test.tsx | 3 +- .../admin/components/PluginDetailsPanel.tsx | 260 ++++++++++++++---- .../features/plugins/admin/helpers.test.ts | 2 + public/app/features/plugins/admin/helpers.ts | 8 + public/app/features/plugins/admin/types.ts | 8 + public/locales/en-US/grafana.json | 15 +- public/locales/pseudo-LOCALE/grafana.json | 15 +- 9 files changed, 253 insertions(+), 63 deletions(-) diff --git a/.betterer.results b/.betterer.results index 89b413d6023..8db179df663 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5501,7 +5501,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], "public/app/features/plugins/admin/components/PluginDetailsPanel.tsx:5381": [ - [0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] + [0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], "public/app/features/plugins/admin/components/PluginDetailsSignature.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 36d49c2dbd7..f79968d47ae 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -37,6 +37,8 @@ export async function getPluginDetails(id: string): Promise { it('should render report abuse section for non-core plugins', () => { render(); expect(screen.getByText('Report a concern')).toBeInTheDocument(); - expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument(); }); it('should not render report abuse section for core plugins', () => { @@ -117,6 +116,6 @@ describe('PluginDetailsPanel', () => { it('should respect custom width prop', () => { render(); const panel = screen.getByTestId('plugin-details-panel'); - expect(panel).toHaveStyle({ maxWidth: '300px' }); + expect(panel).toHaveStyle({ width: '300px' }); }); }); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index 95b963097b7..7d7612dc814 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -1,8 +1,22 @@ import { css } from '@emotion/css'; +import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage'; -import { Stack, Text, LinkButton, Box, TextLink, useStyles2 } from '@grafana/ui'; +import { + Stack, + Text, + LinkButton, + Box, + TextLink, + CollapsableSection, + Tooltip, + Icon, + Modal, + Button, + useStyles2, +} from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { formatDate } from 'app/core/internationalization/dates'; @@ -16,73 +30,203 @@ type Props = { export function PluginDetailsPanel(props: Props): React.ReactElement | null { const { pluginExtentionsInfo, plugin, width = '250px' } = props; + const [reportAbuseModalOpen, setReportAbuseModalOpen] = useState(false); + + const normalizeURL = (url: string | undefined) => url?.replace(/\/$/, ''); + + const customLinks = plugin.details?.links?.filter((link) => { + const customLinksFiltered = ![plugin.url, plugin.details?.licenseUrl, plugin.details?.documentationUrl] + .map(normalizeURL) + .includes(normalizeURL(link.url)); + return customLinksFiltered; + }); + const shouldRenderLinks = plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl; + const styles = useStyles2(getStyles); - return ( - - - - {pluginExtentionsInfo.map((infoItem, index) => { - return ( - - {infoItem.label + ':'} -
      {infoItem.value}
      -
      - ); - })} - {plugin.updatedAt && ( - - - Last updated: - {' '} - {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })} - - )} - {plugin?.details?.lastCommitDate && ( - - - Last commit date: - {' '} - - {formatDate(new Date(plugin.details.lastCommitDate), { - day: 'numeric', - month: 'short', - year: 'numeric', - })} - - - )} -
      -
      + const onClickReportConcern = (pluginId: string) => { + setReportAbuseModalOpen(true); + reportInteraction('plugin_detail_report_concern', { + plugin_id: pluginId, + }); + }; - {plugin?.details?.links && plugin.details?.links?.length > 0 && ( + return ( + <> + - - Links - - {plugin.details.links.map((link, index) => ( - - {link.name} - - ))} + {pluginExtentionsInfo.map((infoItem, index) => { + return ( + + {infoItem.label + ':'} +
      {infoItem.value}
      +
      + ); + })} + {plugin.updatedAt && ( + + + Last updated: + {' '} + + {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })} + + + )} + {plugin?.details?.lastCommitDate && ( + + + Last commit date: + {' '} + + {formatDate(new Date(plugin.details.lastCommitDate), { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + )}
      - )} - - {!plugin?.isCore && ( - - - - Report a concern + {shouldRenderLinks && ( + <> + + + {plugin.url && ( + + Repository + + )} + {plugin.raiseAnIssueUrl && ( + + Raise an issue + + )} + {plugin.details?.licenseUrl && ( + + License + + )} + {plugin.details?.documentationUrl && ( + + Documentation + + )} + + + + )} + {customLinks && customLinks?.length > 0 && ( + + + + Custom links + + + These links are provided by the plugin developer to offer additional, developer-specific + resources and information +
      + } + placement="right-end" + > + + + + } + > + + {customLinks.map((link, index) => ( + + {link.name} + + ))} + + + + )} + {!plugin?.isCore && ( + + + + Report a concern + + + Report issues related to malicious or harmful plugins directly to Grafana Labs. +
      + } + placement="right-end" + > + + + + } + > + + + + + + )} + + {reportAbuseModalOpen && ( + Report a plugin concern
      } + isOpen + onDismiss={() => setReportAbuseModalOpen(false)} + > + + + + This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email + us at:{' '} + + integrations@grafana.com + + + + Note: For general plugin issues like bugs or feature requests, please contact the plugin author using + the provided links.{' '} + - - Contact Grafana Labs - - + + + + + )} - + ); } diff --git a/public/app/features/plugins/admin/helpers.test.ts b/public/app/features/plugins/admin/helpers.test.ts index b47c5bf774b..b1b109f2ca5 100644 --- a/public/app/features/plugins/admin/helpers.test.ts +++ b/public/app/features/plugins/admin/helpers.test.ts @@ -217,6 +217,7 @@ describe('Plugins/Helpers', () => { updatedAt: '2021-05-18T14:53:01.000Z', isFullyInstalled: false, angularDetected: false, + url: 'https://github.com/alexanderzobnin/grafana-zabbix', }); }); @@ -354,6 +355,7 @@ describe('Plugins/Helpers', () => { installedVersion: '4.2.2', isFullyInstalled: true, angularDetected: false, + url: 'https://github.com/alexanderzobnin/grafana-zabbix', }); }); diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 172a32e088f..2f7814cc729 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -121,6 +121,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C signatureType, versionSignatureType, versionSignedByOrgName, + url, + raiseAnIssueUrl, } = plugin; const isDisabled = !!error || isDisabledSecretsPlugin(typeCode); @@ -158,6 +160,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C angularDetected, isFullyInstalled: isDisabled, latestVersion: plugin.version, + url, + raiseAnIssueUrl, }; } @@ -174,6 +178,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat hasUpdate, accessControl, angularDetected, + raiseAnIssueUrl, } = plugin; const isDisabled = !!error || isDisabledSecretsPlugin(type); @@ -208,6 +213,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat isFullyInstalled: true, iam: plugin.iam, latestVersion: plugin.latestVersion, + raiseAnIssueUrl, }; } @@ -271,6 +277,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e isFullyInstalled: Boolean(local) || isDisabled, iam: local?.iam, latestVersion: local?.latestVersion || remote?.version || '', + url: remote?.url || '', + raiseAnIssueUrl: remote?.raiseAnIssueUrl || local?.raiseAnIssueUrl, }; } diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 031f6c1543c..f3d5783ae3c 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -64,6 +64,8 @@ export interface CatalogPlugin extends WithAccessControlMetadata { isUpdatingFromInstance?: boolean; iam?: IdentityAccessManagement; isProvisioned?: boolean; + url?: string; + raiseAnIssueUrl?: string; } export interface CatalogPluginDetails { @@ -79,6 +81,8 @@ export interface CatalogPluginDetails { iam?: IdentityAccessManagement; changelog?: string; lastCommitDate?: string; + licenseUrl?: string; + documentationUrl?: string; signatureType?: PluginSignatureType; signature?: PluginSignatureStatus; } @@ -143,6 +147,9 @@ export type RemotePlugin = { versionStatus: string; angularDetected?: boolean; lastCommitDate?: string; + licenseUrl?: string; + documentationUrl?: string; + raiseAnIssueUrl?: string; }; // The available status codes on GCOM are available here: @@ -190,6 +197,7 @@ export type LocalPlugin = WithAccessControlMetadata & { dependencies: PluginDependencies; angularDetected: boolean; iam?: IdentityAccessManagement; + raiseAnIssueUrl?: string; }; interface IdentityAccessManagement { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2c90bf29511..b7ea5026077 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2781,17 +2781,30 @@ }, "labels": { "contactGrafanaLabs": "Contact Grafana Labs", + "customLinks": "Custom links ", + "customLinksTooltip": "These links are provided by the plugin developer to offer additional, developer-specific resources and information", "dependencies": "Dependencies", + "documentation": "Documentation", "downloads": "Downloads", "from": "From", "installedVersion": "Installed Version", "lastCommitDate": "Last commit date:", "latestVersion": "Latest Version", - "links": "Links ", + "license": "License", + "raiseAnIssue": "Raise an issue", "reportAbuse": "Report a concern ", + "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.", + "repository": "Repository", "signature": "Signature", "status": "Status", "updatedAt": "Last updated:" + }, + "modal": { + "cancel": "Cancel", + "copyEmail": "Copy email address", + "description": "This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email us at: ", + "node": "Note: For general plugin issues like bugs or feature requests, please contact the plugin author using the provided links. ", + "title": "Report a plugin concern" } }, "empty-state": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 61b47f9f9a5..9543a62f07e 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2781,17 +2781,30 @@ }, "labels": { "contactGrafanaLabs": "Cőʼnŧäčŧ Ğřäƒäʼnä Ŀäþş", + "customLinks": "Cūşŧőm ľįʼnĸş ", + "customLinksTooltip": "Ŧĥęşę ľįʼnĸş äřę přővįđęđ þy ŧĥę pľūģįʼn đęvęľőpęř ŧő őƒƒęř äđđįŧįőʼnäľ, đęvęľőpęř-şpęčįƒįč řęşőūřčęş äʼnđ įʼnƒőřmäŧįőʼn", "dependencies": "Đępęʼnđęʼnčįęş", + "documentation": "Đőčūmęʼnŧäŧįőʼn", "downloads": "Đőŵʼnľőäđş", "from": "Fřőm", "installedVersion": "Ĩʼnşŧäľľęđ Vęřşįőʼn", "lastCommitDate": "Ŀäşŧ čőmmįŧ đäŧę:", "latestVersion": "Ŀäŧęşŧ Vęřşįőʼn", - "links": "Ŀįʼnĸş ", + "license": "Ŀįčęʼnşę", + "raiseAnIssue": "Ŗäįşę äʼn įşşūę", "reportAbuse": "Ŗępőřŧ ä čőʼnčęřʼn ", + "reportAbuseTooltip": "Ŗępőřŧ įşşūęş řęľäŧęđ ŧő mäľįčįőūş őř ĥäřmƒūľ pľūģįʼnş đįřęčŧľy ŧő Ğřäƒäʼnä Ŀäþş.", + "repository": "Ŗępőşįŧőřy", "signature": "Ŝįģʼnäŧūřę", "status": "Ŝŧäŧūş", "updatedAt": "Ŀäşŧ ūpđäŧęđ:" + }, + "modal": { + "cancel": "Cäʼnčęľ", + "copyEmail": "Cőpy ęmäįľ äđđřęşş", + "description": "Ŧĥįş ƒęäŧūřę įş ƒőř řępőřŧįʼnģ mäľįčįőūş őř ĥäřmƒūľ þęĥävįőūř ŵįŧĥįʼn pľūģįʼnş. Főř pľūģįʼn čőʼnčęřʼnş, ęmäįľ ūş äŧ: ", + "node": "Ńőŧę: Főř ģęʼnęřäľ pľūģįʼn įşşūęş ľįĸę þūģş őř ƒęäŧūřę řęqūęşŧş, pľęäşę čőʼnŧäčŧ ŧĥę pľūģįʼn äūŧĥőř ūşįʼnģ ŧĥę přővįđęđ ľįʼnĸş. ", + "title": "Ŗępőřŧ ä pľūģįʼn čőʼnčęřʼn" } }, "empty-state": { From ae9837b793e97f683d1517d270b719c99656e556 Mon Sep 17 00:00:00 2001 From: Tito Lins Date: Thu, 13 Feb 2025 11:36:45 +0100 Subject: [PATCH 33/78] Alerting: Add alertmanager integration tests (#100106) --- .github/CODEOWNERS | 1 + Makefile | 8 + .../docker/blocks/stateful_webhook/Dockerfile | 12 + .../stateful_webhook/docker-compose.yaml | 5 + devenv/docker/blocks/stateful_webhook/main.go | 149 +++++++ go.mod | 2 + go.sum | 4 + go.work.sum | 1 + .../alertmanager/alertmanager_scenario.go | 386 ++++++++++++++++++ pkg/tests/alertmanager/alertmanager_test.go | 97 +++++ pkg/tests/alertmanager/grafana.go | 75 ++++ pkg/tests/alertmanager/loki.go | 152 +++++++ pkg/tests/alertmanager/postgres.go | 41 ++ pkg/tests/alertmanager/webhook.go | 85 ++++ ...na_alertmanager_integration_test_images.go | 41 ++ 15 files changed, 1059 insertions(+) create mode 100644 devenv/docker/blocks/stateful_webhook/Dockerfile create mode 100644 devenv/docker/blocks/stateful_webhook/docker-compose.yaml create mode 100644 devenv/docker/blocks/stateful_webhook/main.go create mode 100644 pkg/tests/alertmanager/alertmanager_scenario.go create mode 100644 pkg/tests/alertmanager/alertmanager_test.go create mode 100644 pkg/tests/alertmanager/grafana.go create mode 100644 pkg/tests/alertmanager/loki.go create mode 100644 pkg/tests/alertmanager/postgres.go create mode 100644 pkg/tests/alertmanager/webhook.go create mode 100644 tools/setup_grafana_alertmanager_integration_test_images.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cbb2fa1a54f..a349655800b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -241,6 +241,7 @@ /devenv/dev-dashboards/extensions/ @grafana/plugins-platform-frontend /devenv/docker/blocks/alert_webhook_listener/ @grafana/alerting-backend +/devenv/docker/blocks/stateful_webhook/ @grafana/alerting-backend /devenv/docker/blocks/caddy_tls/ @grafana/alerting-backend /devenv/docker/blocks/clickhouse/ @grafana/partner-datasources /devenv/docker/blocks/collectd/ @grafana/observability-metrics diff --git a/Makefile b/Makefile index 2721c2830b8..11cee0a4255 100644 --- a/Makefile +++ b/Makefile @@ -271,6 +271,14 @@ test-go-integration-alertmanager: ## Run integration tests for the remote alertm AM_URL=http://localhost:8080 AM_TENANT_ID=test \ $(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestIntegrationRemoteAlertmanager" -covermode=atomic -timeout=5m ./pkg/services/ngalert/... +.PHONY: test-go-integration-grafana-alertmanager +test-go-integration-grafana-alertmanager: ## Run integration tests for the grafana alertmanager + @echo "test grafana alertmanager integration tests" + @export GRAFANA_VERSION=11.5.0-81938; \ + $(GO) run tools/setup_grafana_alertmanager_integration_test_images.go; \ + $(GO) clean -testcache; \ + $(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestAlertmanagerIntegration" -covermode=atomic -timeout=10m ./pkg/tests/alertmanager/... + .PHONY: test-go-integration-postgres test-go-integration-postgres: devenv-postgres ## Run integration tests for postgres backend with flags. @echo "test backend integration postgres tests" diff --git a/devenv/docker/blocks/stateful_webhook/Dockerfile b/devenv/docker/blocks/stateful_webhook/Dockerfile new file mode 100644 index 00000000000..03b50db2135 --- /dev/null +++ b/devenv/docker/blocks/stateful_webhook/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23.5 + +ADD main.go /go/src/webhook/main.go + +WORKDIR /go/src/webhook + +RUN mkdir /tmp/logs +RUN go build -o /bin main.go + +ENV PORT=8080 + +ENTRYPOINT [ "/bin/main" ] diff --git a/devenv/docker/blocks/stateful_webhook/docker-compose.yaml b/devenv/docker/blocks/stateful_webhook/docker-compose.yaml new file mode 100644 index 00000000000..7217516c4e9 --- /dev/null +++ b/devenv/docker/blocks/stateful_webhook/docker-compose.yaml @@ -0,0 +1,5 @@ + stateful_webhook: + build: + context: docker/blocks/stateful_webhook + ports: + - "8080:8080" diff --git a/devenv/docker/blocks/stateful_webhook/main.go b/devenv/docker/blocks/stateful_webhook/main.go new file mode 100644 index 00000000000..926cf4e593e --- /dev/null +++ b/devenv/docker/blocks/stateful_webhook/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +type Event struct { + Status string `json:"status"` + TimeNow time.Time `json:"timeNow"` + StartsAt time.Time `json:"startsAt"` + Node string `json:"node"` + DeltaLastSeconds float64 `json:"deltaLastSeconds"` + DeltaStartSeconds float64 `json:"deltaStartSeconds"` +} + +type Notification struct { + Alerts []Alert `json:"alerts"` + CommonAnnotations map[string]string `json:"commonAnnotations"` + CommonLabels map[string]string `json:"commonLabels"` + ExternalURL string `json:"externalURL"` + GroupKey string `json:"groupKey"` + GroupLabels map[string]string `json:"groupLabels"` + Message string `json:"message"` + OrgID int `json:"orgId"` + Receiver string `json:"receiver"` + State string `json:"state"` + Status string `json:"status"` + Title string `json:"title"` + TruncatedAlerts int `json:"truncatedAlerts"` + Version string `json:"version"` +} + +type Alert struct { + Annotations map[string]string `json:"annotations"` + DashboardURL string `json:"dashboardURL"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Fingerprint string `json:"fingerprint"` + GeneratorURL string `json:"generatorURL"` + Labels map[string]string `json:"labels"` + PanelURL string `json:"panelURL"` + SilenceURL string `json:"silenceURL"` + Status string `json:"status"` + ValueString string `json:"valueString"` + Values map[string]any `json:"values"` +} + +type NotificationHandler struct { + startedAt time.Time + stats map[string]int + hist []Event + m sync.Mutex +} + +func NewNotificationHandler() *NotificationHandler { + return &NotificationHandler{ + startedAt: time.Now(), + stats: make(map[string]int), + hist: make([]Event, 0), + } +} + +func (ah *NotificationHandler) Notify(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + return + } + n := Notification{} + if err := json.Unmarshal(b, &n); err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + return + } + log.Printf("got notification from: %s. a: %v", r.RemoteAddr, n) + + ah.m.Lock() + defer ah.m.Unlock() + + addr := r.RemoteAddr + if split := strings.Split(r.RemoteAddr, ":"); len(split) > 0 { + addr = split[0] + } + + a := n.Alerts[0] + + timeNow := time.Now() + + ah.stats[n.Status]++ + + var d time.Duration + if len(ah.hist) > 0 { + last := ah.hist[len(ah.hist)-1] + d = timeNow.Sub(last.TimeNow) + } + + ah.hist = append(ah.hist, Event{ + Status: n.Status, + StartsAt: a.StartsAt, + TimeNow: timeNow, + Node: addr, + DeltaLastSeconds: d.Seconds(), + DeltaStartSeconds: timeNow.Sub(ah.startedAt).Seconds(), + }) +} + +func (ah *NotificationHandler) GetNotifications(w http.ResponseWriter, _ *http.Request) { + ah.m.Lock() + defer ah.m.Unlock() + w.Header().Set("Content-Type", "application/json") + + res, err := json.MarshalIndent(map[string]any{"stats": ah.stats, "history": ah.hist}, "", "\t") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + //nolint:errcheck + w.Write([]byte(`{"error":"failed to marshal alerts"}`)) + log.Printf("failed to marshal alerts: %v\n", err) + return + } + + log.Printf("requested current state\n%v\n", string(res)) + + _, err = w.Write(res) + if err != nil { + log.Printf("failed to write response: %v\n", err) + } +} + +func main() { + ah := NewNotificationHandler() + + http.HandleFunc("/ready", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + http.HandleFunc("/notify", ah.Notify) + http.HandleFunc("/notifications", ah.GetNotifications) + + log.Println("Listening") + //nolint:errcheck + http.ListenAndServe("0.0.0.0:8080", nil) +} diff --git a/go.mod b/go.mod index 3ded590b054..9b05a5c2234 100644 --- a/go.mod +++ b/go.mod @@ -217,6 +217,8 @@ require ( github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage ) +require github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend + require ( cel.dev/expr v0.19.0 // indirect cloud.google.com/go v0.116.0 // indirect diff --git a/go.sum b/go.sum index f73dcbe3a18..8f98d32599f 100644 --- a/go.sum +++ b/go.sum @@ -1297,6 +1297,8 @@ github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I= github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -1527,6 +1529,8 @@ github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 h1:jxJJ5z0GxqhWFbQU github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447/go.mod h1:IxsY6mns6Q5sAnWcrptrgUrSglTZJXH/kXr9nbpb/9I= github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e h1:UlEET0InuoFautfaFp8lDrNF7rPHYXuBMrzwWx9XqFY= github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e/go.mod h1:IGRj8oOoxwJbHBYl1+OhS9UjQR0dv6SQOep7HqmtyFU= +github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8= +github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y= github.com/grafana/grafana-app-sdk v0.31.0 h1:/mFCcx+YqG8cWAi9hePDJQxIdtXDClDIDRgZwHkksFk= github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs= github.com/grafana/grafana-app-sdk/logging v0.30.0 h1:K/P/bm7Cp7Di4tqIJ3EQz2+842JozQGRaz62r95ApME= diff --git a/go.work.sum b/go.work.sum index 1be6908c9d8..832ae031f57 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1455,6 +1455,7 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDs github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg= github.com/go-zookeeper/zk v1.0.3/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= diff --git a/pkg/tests/alertmanager/alertmanager_scenario.go b/pkg/tests/alertmanager/alertmanager_scenario.go new file mode 100644 index 00000000000..719cccef4ab --- /dev/null +++ b/pkg/tests/alertmanager/alertmanager_scenario.go @@ -0,0 +1,386 @@ +package alertmanager + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/grafana/e2e" + gapi "github.com/grafana/grafana-api-golang-client" + "github.com/stretchr/testify/require" +) + +const ( + defaultNetworkName = "e2e-grafana-am" +) + +type AlertRuleConfig struct { + PendingPeriod string + GroupEvaluationIntervalSeconds int64 +} + +type NotificationPolicyCfg struct { + GroupWait string + GroupInterval string + RepeatInterval string +} + +type ProvisionCfg struct { + AlertRuleConfig + NotificationPolicyCfg +} + +// AlertmanagerScenario is a helper for writing tests which require some number of AM +// configured to communicate with some number of Grafana instances. +type AlertmanagerScenario struct { + *e2e.Scenario + + Grafanas map[string]*GrafanaService + Webhook *WebhookService + Postgres *PostgresService + Loki *LokiService +} + +func NewAlertmanagerScenario() (*AlertmanagerScenario, error) { + s, err := e2e.NewScenario(getNetworkName()) + if err != nil { + return nil, err + } + + return &AlertmanagerScenario{ + Scenario: s, + Grafanas: make(map[string]*GrafanaService), + }, nil +} + +// Setup starts a Grafana AM cluster of size n and all required dependencies +func (s *AlertmanagerScenario) Start(t *testing.T, n int, peerTimeout string, stopOnExtraDedup bool) { + is := getInstances(n) + ips := mapInstancePeers(is) + + // start dependencies in one go + require.NoError( + t, + s.StartAndWaitReady([]e2e.Service{ + s.NewWebhookService("webhook"), + s.NewLokiService("loki"), + s.NewPostgresService("postgres"), + }...), + ) + + for i, ps := range ips { + require.NoError(t, s.StartAndWaitReady(s.NewGrafanaService(i, ps, peerTimeout, stopOnExtraDedup))) + } + + // wait for instances to come online and cluster to be properly configured + time.Sleep(30 * time.Second) +} + +// Provision provisions all required resources for the test +func (s *AlertmanagerScenario) Provision(t *testing.T, cfg ProvisionCfg) { //}*GrafanaClient { + c, err := s.NewGrafanaClient("grafana-1", 1) + require.NoError(t, err) + + dsUID := "integration-testdata" + + // setup resources + _, err = c.NewDataSource(&gapi.DataSource{ + Name: "grafana-testdata-datasource", + Type: "grafana-testdata-datasource", + Access: "proxy", + UID: dsUID, + }) + require.NoError(t, err) + + // setup loki for state history + _, err = c.NewDataSource(&gapi.DataSource{ + Name: "loki", + Type: "loki", + URL: "http://loki:3100", + Access: "proxy", + }) + require.NoError(t, err) + + _, err = c.NewContactPoint(&gapi.ContactPoint{ + Name: "webhook", + Type: "webhook", + Settings: map[string]any{ + "url": "http://webhook:8080/notify", + }, + }) + require.NoError(t, err) + + require.NoError(t, c.SetNotificationPolicyTree(&gapi.NotificationPolicyTree{ + Receiver: "webhook", + GroupWait: cfg.GroupWait, + GroupInterval: cfg.GroupInterval, + RepeatInterval: cfg.RepeatInterval, + })) + + f, err := c.NewFolder("integration_test") + require.NoError(t, err) + + r := &gapi.AlertRule{ + Title: "integration rule", + Condition: "C", + FolderUID: f.UID, + ExecErrState: gapi.ErrError, + NoDataState: gapi.NoData, + For: cfg.PendingPeriod, + RuleGroup: "test", + Data: []*gapi.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: gapi.RelativeTimeRange{ + From: 600, + To: 0, + }, + DatasourceUID: dsUID, + Model: json.RawMessage(fmt.Sprintf(`{ + "refId":"A", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "%s" + }, + "hide":false, + "range":false, + "instant":true, + "intervalMs":1000, + "maxDataPoints":43200, + "pulseWave": { + "offCount": 6, + "offValue": 0, + "onCount": 10, + "onValue": 10, + "timeStep": 10 + }, + "refId": "A", + "scenarioId": "predictable_pulse", + "seriesCount": 1 + }`, dsUID)), + }, + { + RefID: "B", + RelativeTimeRange: gapi.RelativeTimeRange{ + From: 0, + To: 0, + }, + DatasourceUID: "__expr__", + Model: json.RawMessage(`{ + "conditions": [ + { + "evaluator": { + "params": [ + 0, + 0 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "datasource": { + "name": "Expression", + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "A", + "intervalMs": 1000, + "maxDataPoints": 43200, + "reducer": "last", + "refId": "B", + "type": "reduce" + }`), + }, + { + RefID: "C", + RelativeTimeRange: gapi.RelativeTimeRange{ + From: 0, + To: 0, + }, + DatasourceUID: "__expr__", + Model: json.RawMessage(`{ + "conditions": [ + { + "evaluator": { + "params": [ + 0, + 0 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "B" + ] + }, + "reducer": { + "params": [], + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "hide": false, + "isPaused": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "C", + "expression": "B", + "type": "threshold" + }`), + }, + }, + } + _, err = c.NewAlertRule(r) + require.NoError(t, err) + + require.NoError(t, c.SetAlertRuleGroup(gapi.RuleGroup{ + Title: "test", + FolderUID: f.UID, + Interval: cfg.GroupEvaluationIntervalSeconds, + Rules: []gapi.AlertRule{*r}, + })) +} + +// NewGrafanaService creates a new Grafana instance. +func (s *AlertmanagerScenario) NewGrafanaService(name string, peers []string, peerTimeout string, stopOnExtraDedup bool) *GrafanaService { + flags := map[string]string{} + + ft := []string{ + "alertStateHistoryLokiSecondary", + "alertStateHistoryLokiPrimary", + "alertStateHistoryLokiOnly", + "alertingAlertmanagerExtraDedupStage", + } + if stopOnExtraDedup { + ft = append(ft, "alertingAlertmanagerExtraDedupStageStopPipeline") + } + envVars := map[string]string{ + //"GF_LOG_MODE": "file", // disable console logging + "GF_LOG_LEVEL": "warn", + "GF_FEATURE_TOGGLES_ENABLE": strings.Join(ft, ","), + "GF_UNIFIED_ALERTING_ENABLED": "true", + "GF_UNIFIED_ALERTING_EXECUTE_ALERTS": "true", + "GF_UNIFIED_ALERTING_HA_PEER_TIMEOUT": peerTimeout, + "GF_UNIFIED_ALERTING_HA_RECONNECT_TIMEOUT": "2m", + "GF_UNIFIED_ALERTING_HA_LISTEN_ADDRESS": ":9094", + "GF_UNIFIED_ALERTING_HA_PEERS": strings.Join(peers, ","), + "GF_UNIFIED_ALERTING_STATE_HISTORY_ENABLED": "true", + "GF_UNIFIED_ALERTING_STATE_HISTORY_BACKEND": "loki", + "GF_UNIFIED_ALERTING_STATE_HISTORY_LOKI_REMOTE_URL": "http://loki:3100", + "GF_DATABASE_TYPE": "postgres", + "GF_DATABASE_HOST": "postgres:5432", + "GF_DATABASE_NAME": "grafana", + "GF_DATABASE_USER": "postgres", + "GF_DATABASE_PASSWORD": "password", + "GF_DATABASE_SSL_MODE": "disable", + } + + g := NewGrafanaService(name, flags, envVars) + + s.Grafanas[name] = g + return g +} + +// NewGrafanaService creates a new Grafana API client for the requested instance. +func (s *AlertmanagerScenario) NewGrafanaClient(grafanaName string, orgID int64) (*GrafanaClient, error) { + g, ok := s.Grafanas[grafanaName] + if !ok { + return nil, fmt.Errorf("unknown grafana instance: %s", grafanaName) + } + + return NewGrafanaClient(g.HTTPEndpoint(), orgID) +} + +func (s *AlertmanagerScenario) NewWebhookClient() (*WebhookClient, error) { + return NewWebhookClient("http://" + s.Webhook.HTTPEndpoint()) +} + +func (s *AlertmanagerScenario) NewWebhookService(name string) *WebhookService { + ws := NewWebhookService(name, nil, nil) + s.Webhook = ws + + return ws +} + +func (s *AlertmanagerScenario) NewLokiService(name string) *LokiService { + ls := NewLokiService(name, map[string]string{"--config.file": "/etc/loki/local-config.yaml"}, nil) + s.Loki = ls + + return ls +} + +func (s *AlertmanagerScenario) NewPostgresService(name string) *PostgresService { + ps := NewPostgresService(name, map[string]string{"POSTGRES_PASSWORD": "password", "POSTGRES_DB": "grafana"}) + s.Postgres = ps + + return ps +} + +func (s *AlertmanagerScenario) NewLokiClient() (*LokiClient, error) { + return NewLokiClient("http://" + s.Loki.HTTPEndpoint()) +} + +func getNetworkName() string { + // If the E2E_NETWORK_NAME is set, use that for the network name. + // Otherwise, return the default network name. + if os.Getenv("E2E_NETWORK_NAME") != "" { + return os.Getenv("E2E_NETWORK_NAME") + } + + return defaultNetworkName +} + +func getInstances(n int) []string { + is := make([]string, n) + + for i := 0; i < n; i++ { + is[i] = "grafana-" + strconv.Itoa(i+1) + } + + return is +} + +func getPeers(i string, is []string) []string { + peers := make([]string, 0, len(is)-1) + + for _, p := range is { + if p != i { + peers = append(peers, p+":9094") + } + } + + return peers +} + +func mapInstancePeers(is []string) map[string][]string { + mIs := make(map[string][]string, len(is)) + + for _, i := range is { + mIs[i] = getPeers(i, is) + } + + return mIs +} diff --git a/pkg/tests/alertmanager/alertmanager_test.go b/pkg/tests/alertmanager/alertmanager_test.go new file mode 100644 index 00000000000..0f127ea2ab6 --- /dev/null +++ b/pkg/tests/alertmanager/alertmanager_test.go @@ -0,0 +1,97 @@ +package alertmanager + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAlertmanagerIntegration_ExtraDedupStage(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + t.Run("assert no flapping alerts when stopOnExtraDedup is enabled", func(t *testing.T) { + s, err := NewAlertmanagerScenario() + require.NoError(t, err) + defer s.Close() + + s.Start(t, 20, "15s", true) + s.Provision(t, ProvisionCfg{ + AlertRuleConfig: AlertRuleConfig{ + PendingPeriod: "30s", + GroupEvaluationIntervalSeconds: 10, + }, + NotificationPolicyCfg: NotificationPolicyCfg{ + GroupWait: "30s", + GroupInterval: "1m", + RepeatInterval: "30m", + }, + }) + + wc, err := s.NewWebhookClient() + require.NoError(t, err) + + lc, err := s.NewLokiClient() + require.NoError(t, err) + + // notifications only start arriving after 2 to 3 minutes so we wait for that + time.Sleep(time.Minute * 2) + + timeout := time.After(5 * time.Minute) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + nr, err := wc.GetNotifications() + if err != nil { + t.Logf("failed to get alert notifications: %v\n", err) + continue + } + + // get the latest state for the alert from loki + st, err := lc.GetCurrentAlertState() + if err != nil { + t.Logf("failed to get alert state: %v\n", err) + continue + } + + // if the last state is not normal, ignore + // we might be missing other cases of flapping notifications but for now we are only interested in this one + // (alerting notification when state is already normal) + if st.State != AlertStateNormal { + continue + } + + // history is ordered - fetch the first notification that is after the last state change + var i int + for i = range nr.History { + if nr.History[i].TimeNow.After(st.Timestamp) { + break + } + } + + // if all notifications are from before the last state change, we can wait a bit more + if nr.History[i].TimeNow.Before(st.Timestamp) { + continue + } + + // for all notifications after the last state change, check if there is a firing one + for ; i < len(nr.History); i++ { + notification := nr.History[i] + if notification.Status == "firing" { + t.Errorf("flapping notifications - got firing notification when alert was resolved, state = %#v, notification = %#v", st, notification) + t.FailNow() + } + } + + case <-timeout: + // if after the timeout there are no such cases, we assume there are no flapping notifications + return + } + } + }) +} diff --git a/pkg/tests/alertmanager/grafana.go b/pkg/tests/alertmanager/grafana.go new file mode 100644 index 00000000000..7d67ffdf53c --- /dev/null +++ b/pkg/tests/alertmanager/grafana.go @@ -0,0 +1,75 @@ +package alertmanager + +import ( + _ "embed" + "fmt" + "net/url" + "os" + + "github.com/grafana/e2e" + gapi "github.com/grafana/grafana-api-golang-client" +) + +const ( + grafanaBinary = "/run.sh" + grafanaHTTPPort = 3000 +) + +// GetDefaultImage returns the Docker image to use to run the Grafana.. +func GetGrafanaImage() string { + if img := os.Getenv("GRAFANA_IMAGE"); img != "" { + return img + } + + if version := os.Getenv("GRAFANA_VERSION"); version != "" { + return "grafana/grafana-enterprise-dev:" + version + } + + panic("Provide GRAFANA_VERSION or GRAFANA_IMAGE") +} + +type GrafanaService struct { + *e2e.HTTPService +} + +func NewGrafanaService(name string, flags, envVars map[string]string) *GrafanaService { + svc := &GrafanaService{ + HTTPService: e2e.NewHTTPService( + name, + GetGrafanaImage(), + e2e.NewCommandWithoutEntrypoint(grafanaBinary, e2e.BuildArgs(flags)...), + e2e.NewHTTPReadinessProbe(grafanaHTTPPort, "/ready", 200, 299), + grafanaHTTPPort, + 9094, + ), + } + + svc.SetEnvVars(envVars) + + return svc +} + +type GrafanaClient struct { + *gapi.Client +} + +// NewGrafanaClient creates a client for using the Grafana API. Note we don't bother +// wrapping the client library, and just use it as-is, until we find a reason not to. +func NewGrafanaClient(host string, orgID int64) (*GrafanaClient, error) { + cfg := gapi.Config{ + BasicAuth: url.UserPassword("admin", "admin"), + OrgID: orgID, + HTTPHeaders: map[string]string{ + "X-Disable-Provenance": "true", + }, + } + + client, err := gapi.New(fmt.Sprintf("http://%s/", host), cfg) + if err != nil { + return nil, err + } + + return &GrafanaClient{ + Client: client, + }, nil +} diff --git a/pkg/tests/alertmanager/loki.go b/pkg/tests/alertmanager/loki.go new file mode 100644 index 00000000000..16c6fda0b90 --- /dev/null +++ b/pkg/tests/alertmanager/loki.go @@ -0,0 +1,152 @@ +package alertmanager + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "github.com/grafana/e2e" +) + +const ( + defaultLokiImage = "grafana/loki:latest" + lokiBinary = "/usr/bin/loki" + lokiHTTPPort = 3100 +) + +// GetDefaultImage returns the Docker image to use to run the Loki.. +func GetLokiImage() string { + if img := os.Getenv("LOKI_IMAGE"); img != "" { + return img + } + + return defaultLokiImage +} + +type LokiService struct { + *e2e.HTTPService +} + +func NewLokiService(name string, flags, envVars map[string]string) *LokiService { + svc := &LokiService{ + HTTPService: e2e.NewHTTPService( + name, + GetLokiImage(), + e2e.NewCommandWithoutEntrypoint(lokiBinary, e2e.BuildArgs(flags)...), + e2e.NewHTTPReadinessProbe(lokiHTTPPort, "/ready", 200, 299), + lokiHTTPPort, + ), + } + + svc.SetEnvVars(envVars) + + return svc +} + +type LokiClient struct { + c http.Client + u *url.URL +} + +func NewLokiClient(u string) (*LokiClient, error) { + pu, err := url.Parse(u) + if err != nil { + return nil, err + } + + return &LokiClient{ + c: http.Client{}, + u: pu, + }, nil +} + +type LokiQueryResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Stream struct { + Condition string `json:"condition"` + Current string `json:"current"` + DashboardUID string `json:"dashboardUID"` + Fingerprint string `json:"fingerprint"` + FolderUID string `json:"folderUID"` + From string `json:"from"` + Group string `json:"group"` + LabelsAlertname string `json:"labels_alertname"` + LabelsGrafanaFolder string `json:"labels_grafana_folder"` + OrgID string `json:"orgID"` + PanelID string `json:"panelID"` + Previous string `json:"previous"` + RuleID string `json:"ruleID"` + RuleTitle string `json:"ruleTitle"` + RuleUID string `json:"ruleUID"` + SchemaVersion string `json:"schemaVersion"` + ServiceName string `json:"service_name"` + ValuesB string `json:"values_B"` + ValuesC string `json:"values_C"` + } `json:"stream"` + Values [][]string `json:"values"` + } `json:"result"` + } +} + +type AlertState string + +const ( + AlertStateNormal AlertState = "Normal" + AlertStatePending AlertState = "Pending" + AlertStateAlerting AlertState = "Alerting" +) + +type AlertStateResponse struct { + State AlertState + Timestamp time.Time +} + +// GetCurrentAlertState fetches the current alert state from loki +func (c *LokiClient) GetCurrentAlertState() (*AlertStateResponse, error) { + u := c.u.ResolveReference(&url.URL{Path: "/loki/api/v1/query_range"}) + + vs := url.Values{} + vs.Add("query", `{from="state-history"} | json`) + vs.Add("since", "60s") + + u.RawQuery = vs.Encode() + + resp, err := c.c.Get(u.String()) + if err != nil { + return nil, err + } + //nolint:errcheck + defer resp.Body.Close() + + res := LokiQueryResponse{} + + if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + if res.Status != "success" { + return nil, fmt.Errorf("failed to query state from loki") + } + + if len(res.Data.Result) == 0 { + return nil, fmt.Errorf("empty result from loki") + } + + r := res.Data.Result[0] + it, err := strconv.ParseInt(r.Values[0][0], 10, 0) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp: %v", err) + } + + return &AlertStateResponse{ + State: AlertState(r.Stream.Current), + Timestamp: time.Unix(0, it), + }, nil +} diff --git a/pkg/tests/alertmanager/postgres.go b/pkg/tests/alertmanager/postgres.go new file mode 100644 index 00000000000..3d3fe4577d9 --- /dev/null +++ b/pkg/tests/alertmanager/postgres.go @@ -0,0 +1,41 @@ +package alertmanager + +import ( + "os" + + "github.com/grafana/e2e" +) + +const ( + defaultPostgresImage = "postgres:16.4" + postgresHTTPPort = 5432 +) + +// GetDefaultImage returns the Docker image to use to run the Postgres.. +func GetPostgresImage() string { + if img := os.Getenv("POSTGRES_IMAGE"); img != "" { + return img + } + + return defaultPostgresImage +} + +type PostgresService struct { + *e2e.HTTPService +} + +func NewPostgresService(name string, envVars map[string]string) *PostgresService { + svc := &PostgresService{ + HTTPService: e2e.NewHTTPService( + name, + GetPostgresImage(), + nil, + nil, + postgresHTTPPort, + ), + } + + svc.SetEnvVars(envVars) + + return svc +} diff --git a/pkg/tests/alertmanager/webhook.go b/pkg/tests/alertmanager/webhook.go new file mode 100644 index 00000000000..c90c3f229eb --- /dev/null +++ b/pkg/tests/alertmanager/webhook.go @@ -0,0 +1,85 @@ +package alertmanager + +import ( + "encoding/json" + "net/http" + "net/url" + "time" + + "github.com/grafana/e2e" +) + +const ( + defaultWebhookImage = "webhook-receiver" + webhookBinary = "/bin/main" + webhookHTTPPort = 8080 +) + +type WebhookService struct { + *e2e.HTTPService +} + +func NewWebhookService(name string, flags, envVars map[string]string) *WebhookService { + svc := &WebhookService{ + HTTPService: e2e.NewHTTPService( + name, + "webhook-receiver", + e2e.NewCommandWithoutEntrypoint(webhookBinary, e2e.BuildArgs(flags)...), + e2e.NewHTTPReadinessProbe(webhookHTTPPort, "/ready", 200, 299), + webhookHTTPPort), + } + + svc.SetEnvVars(envVars) + + return svc +} + +type WebhookClient struct { + c http.Client + u *url.URL +} + +func NewWebhookClient(u string) (*WebhookClient, error) { + pu, err := url.Parse(u) + if err != nil { + return nil, err + } + + return &WebhookClient{ + c: http.Client{}, + u: pu, + }, nil +} + +type GetNotificationsResponse struct { + Stats map[string]int `json:"stats"` + History []struct { + Status string `json:"status"` + TimeNow time.Time `json:"timeNow"` + StartsAt time.Time `json:"startsAt"` + Node string `json:"node"` + DeltaLastSeconds float64 `json:"deltaLastSeconds"` + DeltaStartSeconds float64 `json:"deltaStartSeconds"` + } `json:"history"` +} + +// GetNotifications fetches notifications from the webhook server +func (c *WebhookClient) GetNotifications() (*GetNotificationsResponse, error) { + u := c.u.ResolveReference(&url.URL{Path: "/notifications"}) + + resp, err := c.c.Get(u.String()) + if err != nil { + return nil, err + } + //nolint:errcheck + defer resp.Body.Close() + + res := GetNotificationsResponse{} + + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return nil, err + } + + return &res, nil +} diff --git a/tools/setup_grafana_alertmanager_integration_test_images.go b/tools/setup_grafana_alertmanager_integration_test_images.go new file mode 100644 index 00000000000..3b6e1436446 --- /dev/null +++ b/tools/setup_grafana_alertmanager_integration_test_images.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "sync" + + amtests "github.com/grafana/grafana/pkg/tests/alertmanager" +) + +func docker(args []string) { + cmd := exec.Command("docker", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("docker pull failed: %v\n", err) + os.Exit(1) + } +} + +func main() { + var wg sync.WaitGroup + + for _, cmd := range [][]string{ + {"pull", amtests.GetGrafanaImage()}, + {"pull", amtests.GetLokiImage()}, + {"pull", amtests.GetPostgresImage()}, + {"build", "-t", "webhook-receiver", "devenv/docker/blocks/stateful_webhook"}, + } { + wg.Add(1) + + go func(cmd []string) { + defer wg.Done() + + docker(cmd) + }(cmd) + } + + wg.Wait() +} From 1b1954de2887b1b447968e6df2e1d23ca04a06ce Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:59:59 +0100 Subject: [PATCH 34/78] Authz: add support to use folder api to fetch folder tree (#100038) * Add FolderStore interface * Authz: add implementation to use folders api and use it inproc with loopback config * Add tracing and add rest.Config for talking with folder api using access tokens * Restructure test to get rid of circular dependencies in tests * use correct group version kind --------- Co-authored-by: gamab --- pkg/services/authz/{client.go => rbac.go} | 73 ++++++++ pkg/services/authz/rbac/service.go | 56 ++++--- pkg/services/authz/rbac/service_test.go | 3 +- pkg/services/authz/rbac/store/folder_store.go | 158 ++++++++++++++++++ pkg/services/authz/rbac/store/models.go | 18 -- pkg/services/authz/rbac/store/queries.go | 1 - pkg/services/authz/rbac/store/store.go | 39 ----- pkg/services/authz/server.go | 37 ---- pkg/storage/unified/apistore/go.mod | 28 +++- pkg/storage/unified/apistore/go.sum | 30 +++- pkg/storage/unified/apistore/prepare_test.go | 5 + pkg/storage/unified/apistore/store_test.go | 2 +- pkg/storage/unified/apistore/util.go | 40 ----- pkg/storage/unified/apistore/watcher_test.go | 49 +++++- 14 files changed, 367 insertions(+), 172 deletions(-) rename pkg/services/authz/{client.go => rbac.go} (75%) create mode 100644 pkg/services/authz/rbac/store/folder_store.go delete mode 100644 pkg/services/authz/server.go diff --git a/pkg/services/authz/client.go b/pkg/services/authz/rbac.go similarity index 75% rename from pkg/services/authz/client.go rename to pkg/services/authz/rbac.go index 506155df380..cd0465886d3 100644 --- a/pkg/services/authz/client.go +++ b/pkg/services/authz/rbac.go @@ -3,6 +3,8 @@ package authz import ( "context" "errors" + "fmt" + "net/http" "time" "github.com/fullstorydev/grpchan" @@ -11,6 +13,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "k8s.io/client-go/rest" authnlib "github.com/grafana/authlib/authn" authzlib "github.com/grafana/authlib/authz" @@ -22,6 +25,8 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/rbac" "github.com/grafana/grafana/pkg/services/authz/rbac/store" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -64,6 +69,10 @@ func ProvideAuthZClient( // Register the server server := rbac.NewService( sql, + // When running in-proc we get a injection cycle between + // authz client, resource client and apiserver so we need to use + // package level function to get rest config + store.NewAPIFolderStore(tracer, apiserver.GetRestConfig), legacy.NewLegacySQLStores(sql), store.NewUnionPermissionStore( store.NewStaticPermissionStore(acService), @@ -201,3 +210,67 @@ func newCloudLegacyClient(authCfg *Cfg, tracer tracing.Tracer) (authlib.AccessCl return client, nil } + +func RegisterRBACAuthZService( + handler grpcserver.Provider, + db legacysql.LegacyDatabaseProvider, + tracer tracing.Tracer, + reg prometheus.Registerer, + cache cache.Cache, + exchangeClient authnlib.TokenExchanger, + folderAPIURL string, +) { + var folderStore store.FolderStore + // FIXME: for now we default to using database read proxy for folders if the api url is not configured. + // we should remove this and the sql implementation once we have verified that is works correctly + if folderAPIURL == "" { + folderStore = store.NewSQLFolderStore(db, tracer) + } else { + folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) *rest.Config { + return &rest.Config{ + Host: folderAPIURL, + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { + return &tokenExhangeRoundTripper{te: exchangeClient, rt: rt} + }, + QPS: 50, + Burst: 100, + } + }) + } + + server := rbac.NewService( + db, + folderStore, + legacy.NewLegacySQLStores(db), + store.NewSQLPermissionStore(db, tracer), + log.New("authz-grpc-server"), + tracer, + reg, + cache, + ) + + srv := handler.GetServer() + authzv1.RegisterAuthzServiceServer(srv, server) + authzextv1.RegisterAuthzExtentionServiceServer(srv, server) +} + +var _ http.RoundTripper = tokenExhangeRoundTripper{} + +type tokenExhangeRoundTripper struct { + te authnlib.TokenExchanger + rt http.RoundTripper +} + +func (t tokenExhangeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + res, err := t.te.Exchange(r.Context(), authnlib.TokenExchangeRequest{ + Namespace: "*", + Audiences: []string{"folder.grafana.app"}, + }) + + if err != nil { + return nil, fmt.Errorf("create access token: %w", err) + } + + r.Header.Set("X-Access-Token", "Bearer "+res.Token) + return t.rt.RoundTrip(r) +} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 429b568a091..7a365c0d6f5 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -17,7 +17,7 @@ import ( authzv1 "github.com/grafana/authlib/authz/proto/v1" "github.com/grafana/authlib/cache" - claims "github.com/grafana/authlib/types" + "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -41,6 +41,7 @@ type Service struct { authzextv1.UnimplementedAuthzExtentionServiceServer store store.Store + folderStore store.FolderStore permissionStore store.PermissionStore identityStore legacy.LegacyIdentityStore @@ -63,6 +64,7 @@ type Service struct { func NewService( sql legacysql.LegacyDatabaseProvider, + folderStore store.FolderStore, identityStore legacy.LegacyIdentityStore, permissionStore store.PermissionStore, logger log.Logger, @@ -72,6 +74,7 @@ func NewService( ) *Service { return &Service{ store: store.NewStore(sql, tracer), + folderStore: folderStore, permissionStore: permissionStore, identityStore: identityStore, logger: logger, @@ -209,40 +212,42 @@ func (s *Service) validateListRequest(ctx context.Context, req *authzv1.ListRequ return listReq, nil } -func validateNamespace(ctx context.Context, nameSpace string) (claims.NamespaceInfo, error) { +func validateNamespace(ctx context.Context, nameSpace string) (types.NamespaceInfo, error) { if nameSpace == "" { - return claims.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required") + return types.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required") } - authInfo, has := claims.AuthInfoFrom(ctx) + authInfo, has := types.AuthInfoFrom(ctx) if !has { - return claims.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context") + return types.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context") } - if !claims.NamespaceMatches(authInfo.GetNamespace(), nameSpace) { - return claims.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match") + if !types.NamespaceMatches(authInfo.GetNamespace(), nameSpace) { + return types.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match") } - ns, err := claims.ParseNamespace(nameSpace) + ns, err := types.ParseNamespace(nameSpace) if err != nil { - return claims.NamespaceInfo{}, err + return types.NamespaceInfo{}, err } return ns, nil } -func (s *Service) validateSubject(ctx context.Context, subject string) (string, claims.IdentityType, error) { +func (s *Service) validateSubject(ctx context.Context, subject string) (string, types.IdentityType, error) { if subject == "" { return "", "", status.Error(codes.InvalidArgument, "subject is required") } ctxLogger := s.logger.FromContext(ctx) - identityType, userUID, err := claims.ParseTypeID(subject) + identityType, userUID, err := types.ParseTypeID(subject) if err != nil { return "", "", err } + // Permission check currently only checks user, anonymous user, service account and renderer permissions - if !(identityType == claims.TypeUser || identityType == claims.TypeServiceAccount || identityType == claims.TypeAnonymous || identityType == claims.TypeRenderService) { + if !types.IsIdentityType(identityType, types.TypeUser, types.TypeServiceAccount, types.TypeAnonymous, types.TypeRenderService) { ctxLogger.Error("unsupported identity type", "type", identityType) return "", "", status.Error(codes.PermissionDenied, "unsupported identity type") } + return userUID, identityType, nil } @@ -264,30 +269,29 @@ func (s *Service) validateAction(ctx context.Context, group, resource, verb stri return action, nil } -func (s *Service) getIdentityPermissions(ctx context.Context, ns claims.NamespaceInfo, idType claims.IdentityType, userID, action string) (map[string]bool, error) { +func (s *Service) getIdentityPermissions(ctx context.Context, ns types.NamespaceInfo, idType types.IdentityType, userID, action string) (map[string]bool, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getIdentityPermissions") defer span.End() // When checking folder creation permissions, also check edit and admin action sets for folder, as the scoped folder create actions aren't stored in the DB separately var actionSets []string if action == "folders:create" { - actionSets = append(actionSets, "folders:edit") - actionSets = append(actionSets, "folders:admin") + actionSets = append(actionSets, "folders:edit", "folders:admin") } switch idType { - case claims.TypeAnonymous: + case types.TypeAnonymous: return s.getAnonymousPermissions(ctx, ns, action, actionSets) - case claims.TypeRenderService: + case types.TypeRenderService: return s.getRendererPermissions(ctx, action) - case claims.TypeUser, claims.TypeServiceAccount: + case types.TypeUser, types.TypeServiceAccount: return s.getUserPermissions(ctx, ns, userID, action, actionSets) default: return nil, fmt.Errorf("unsupported identity type: %s", idType) } } -func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) { +func (s *Service) getUserPermissions(ctx context.Context, ns types.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserPermissions") defer span.End() @@ -342,7 +346,7 @@ func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInf return res.(map[string]bool), nil } -func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) { +func (s *Service) getAnonymousPermissions(ctx context.Context, ns types.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getAnonymousPermissions") defer span.End() @@ -378,7 +382,7 @@ func (s *Service) getRendererPermissions(ctx context.Context, action string) (ma return map[string]bool{}, nil } -func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) { +func (s *Service) GetUserIdentifiers(ctx context.Context, ns types.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) { uidCacheKey := userIdentifierCacheKey(ns.Value, userUID) if cached, ok := s.idCache.Get(ctx, uidCacheKey); ok { return &cached, nil @@ -397,7 +401,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf userIDQuery = store.UserIdentifierQuery{UserUID: userUID} } userIdentifiers, err := s.store.GetUserIdentifiers(ctx, userIDQuery) - if err != nil || userIdentifiers == nil { + if err != nil { return nil, fmt.Errorf("could not get user internal id: %w", err) } @@ -407,7 +411,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf return userIdentifiers, nil } -func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) { +func (s *Service) getUserTeams(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserTeams") defer span.End() @@ -441,7 +445,7 @@ func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, use return teamIDs, nil } -func (s *Service) getUserBasicRole(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) { +func (s *Service) getUserBasicRole(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserBasicRole") defer span.End() @@ -535,7 +539,7 @@ func (s *Service) checkInheritedPermissions(ctx context.Context, scopeMap map[st return false, nil } -func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo) (folderTree, error) { +func (s *Service) buildFolderTree(ctx context.Context, ns types.NamespaceInfo) (folderTree, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.buildFolderTree") defer span.End() @@ -545,7 +549,7 @@ func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo) } res, err, _ := s.sf.Do(ns.Value+"_buildFolderTree", func() (interface{}, error) { - folders, err := s.store.GetFolders(ctx, ns) + folders, err := s.folderStore.ListFolders(ctx, ns) if err != nil { return nil, fmt.Errorf("could not get folders: %w", err) } diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index 310e7cc8cfc..930f5920e36 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -620,6 +620,7 @@ func setupService() *Service { folderCache: newCacheWrap[folderTree](cache, logger, shortCacheTTL), store: fStore, permissionStore: fStore, + folderStore: fStore, identityStore: &fakeIdentityStore{}, sf: new(singleflight.Group), } @@ -663,7 +664,7 @@ func (f *fakeStore) GetUserPermissions(ctx context.Context, namespace claims.Nam return f.userPermissions, nil } -func (f *fakeStore) GetFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) { +func (f *fakeStore) ListFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) { f.calls++ if f.err { return nil, fmt.Errorf("store error") diff --git a/pkg/services/authz/rbac/store/folder_store.go b/pkg/services/authz/rbac/store/folder_store.go new file mode 100644 index 00000000000..078cb2f4c61 --- /dev/null +++ b/pkg/services/authz/rbac/store/folder_store.go @@ -0,0 +1,158 @@ +package store + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/pager" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +type FolderStore interface { + ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) +} + +type Folder struct { + UID string + ParentUID *string +} + +var _ FolderStore = (*SQLFolderStore)(nil) + +func NewSQLFolderStore(sql legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) *SQLFolderStore { + return &SQLFolderStore{sql, tracer} +} + +type SQLFolderStore struct { + sql legacysql.LegacyDatabaseProvider + tracer tracing.Tracer +} + +var sqlFolders = mustTemplate("folder_query.sql") + +type listFoldersQuery struct { + sqltemplate.SQLTemplate + + Query *FolderQuery + FolderTable string +} + +type FolderQuery struct { + OrgID int64 +} + +func (r listFoldersQuery) Validate() error { + return nil +} + +func newListFolders(sql *legacysql.LegacyDatabaseHelper, query *FolderQuery) listFoldersQuery { + return listFoldersQuery{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + Query: query, + FolderTable: sql.Table("folder"), + } +} + +func (s *SQLFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) { + ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.ListFolders") + defer span.End() + + sql, err := s.sql(ctx) + if err != nil { + return nil, err + } + + query := newListFolders(sql, &FolderQuery{OrgID: ns.OrgID}) + q, err := sqltemplate.Execute(sqlFolders, query) + if err != nil { + return nil, err + } + + rows, err := sql.DB.GetSqlxSession().Query(ctx, q, query.GetArgs()...) + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + if err != nil { + return nil, err + } + + var folders []Folder + for rows.Next() { + var folder Folder + if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil { + return nil, err + } + folders = append(folders, folder) + } + + return folders, nil +} + +var _ FolderStore = (*APIFolderStore)(nil) + +func NewAPIFolderStore(tracer tracing.Tracer, configProvider func(ctx context.Context) *rest.Config) *APIFolderStore { + return &APIFolderStore{tracer, configProvider} +} + +type APIFolderStore struct { + tracer tracing.Tracer + configProvider func(ctx context.Context) *rest.Config +} + +func (s *APIFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) { + ctx, span := s.tracer.Start(ctx, "authz.apistore.ListFolders") + defer span.End() + + client, err := s.client(ctx, ns.Value) + if err != nil { + return nil, fmt.Errorf("create resource client: %w", err) + } + + p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) { + return client.List(ctx, opts) + }) + + const defaultPageSize = 500 + folders := make([]Folder, 0, defaultPageSize) + err = p.EachListItem(ctx, metav1.ListOptions{Limit: defaultPageSize}, func(obj runtime.Object) error { + object, err := utils.MetaAccessor(obj) + if err != nil { + return err + } + + folder := Folder{UID: object.GetName()} + parent := object.GetFolder() + if parent != "" { + folder.ParentUID = &parent + } + + folders = append(folders, folder) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("fetching folders: %w", err) + } + + return folders, nil +} + +func (s *APIFolderStore) client(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) { + client, err := dynamic.NewForConfig(s.configProvider(ctx)) + if err != nil { + return nil, err + } + return client.Resource(folderv0alpha1.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil +} diff --git a/pkg/services/authz/rbac/store/models.go b/pkg/services/authz/rbac/store/models.go index 9abcbb8a257..bc3e6593245 100644 --- a/pkg/services/authz/rbac/store/models.go +++ b/pkg/services/authz/rbac/store/models.go @@ -19,21 +19,3 @@ type UserIdentifierQuery struct { UserID int64 UserUID string } - -type FolderQuery struct { - OrgID int64 -} - -type DashboardQuery struct { - OrgID int64 -} - -type Folder struct { - UID string - ParentUID *string -} - -type Dashboard struct { - UID string - ParentUID *string -} diff --git a/pkg/services/authz/rbac/store/queries.go b/pkg/services/authz/rbac/store/queries.go index 4ad0a3d5e11..10c9a17ea8a 100644 --- a/pkg/services/authz/rbac/store/queries.go +++ b/pkg/services/authz/rbac/store/queries.go @@ -16,7 +16,6 @@ var ( sqlQueryBasicRoles = mustTemplate("basic_role_query.sql") sqlUserIdentifiers = mustTemplate("user_identifier_query.sql") - sqlFolders = mustTemplate("folder_query.sql") ) func mustTemplate(filename string) *template.Template { diff --git a/pkg/services/authz/rbac/store/store.go b/pkg/services/authz/rbac/store/store.go index 9b7c2832b00..124ba38331b 100644 --- a/pkg/services/authz/rbac/store/store.go +++ b/pkg/services/authz/rbac/store/store.go @@ -15,7 +15,6 @@ import ( type Store interface { GetUserIdentifiers(ctx context.Context, query UserIdentifierQuery) (*UserIdentifiers, error) GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, query BasicRoleQuery) (*BasicRole, error) - GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error) } type StoreImpl struct { @@ -104,41 +103,3 @@ func (s *StoreImpl) GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, return &role, nil } - -func (s *StoreImpl) GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error) { - ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetFolders") - defer span.End() - - sql, err := s.sql(ctx) - if err != nil { - return nil, err - } - - query := FolderQuery{OrgID: ns.OrgID} - req := newGetFolders(sql, &query) - q, err := sqltemplate.Execute(sqlFolders, req) - if err != nil { - return nil, err - } - - rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) - defer func() { - if rows != nil { - _ = rows.Close() - } - }() - if err != nil { - return nil, err - } - - var folders []Folder - for rows.Next() { - var folder Folder - if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil { - return nil, err - } - folders = append(folders, folder) - } - - return folders, nil -} diff --git a/pkg/services/authz/server.go b/pkg/services/authz/server.go deleted file mode 100644 index 868e7c2816b..00000000000 --- a/pkg/services/authz/server.go +++ /dev/null @@ -1,37 +0,0 @@ -package authz - -import ( - authzv1 "github.com/grafana/authlib/authz/proto/v1" - cache "github.com/grafana/authlib/cache" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" - authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/grafana/grafana/pkg/services/authz/rbac" - "github.com/grafana/grafana/pkg/services/authz/rbac/store" - "github.com/grafana/grafana/pkg/services/grpcserver" - "github.com/grafana/grafana/pkg/storage/legacysql" - "github.com/prometheus/client_golang/prometheus" -) - -func RegisterRBACAuthZService( - handler grpcserver.Provider, - db legacysql.LegacyDatabaseProvider, - tracer tracing.Tracer, - reg prometheus.Registerer, - cache cache.Cache) { - server := rbac.NewService( - db, - legacy.NewLegacySQLStores(db), - store.NewSQLPermissionStore(db, tracer), - log.New("authz-grpc-server"), - tracer, - reg, - cache, - ) - - srv := handler.GetServer() - authzv1.RegisterAuthzServiceServer(srv, server) - authzextv1.RegisterAuthzExtentionServiceServer(srv, server) -} diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod index a409151a0d4..b18163780f8 100644 --- a/pkg/storage/unified/apistore/go.mod +++ b/pkg/storage/unified/apistore/go.mod @@ -45,14 +45,17 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/RoaringBitmap/roaring v1.9.3 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect @@ -85,6 +88,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.12.0 // indirect @@ -107,7 +111,9 @@ require ( github.com/blevesearch/zapx/v14 v14.3.10 // indirect github.com/blevesearch/zapx/v15 v15.3.16 // indirect github.com/blevesearch/zapx/v16 v16.1.8 // indirect + github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect github.com/bufbuild/protocompile v0.4.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect @@ -117,6 +123,8 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dennwc/varint v1.0.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlmiddlecote/sqlstats v1.0.2 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -137,8 +145,10 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.129.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-kit/log v0.2.1 // indirect + github.com/go-ldap/ldap/v3 v3.4.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -152,6 +162,7 @@ require ( github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/gobwas/glob v0.2.3 // indirect @@ -160,10 +171,12 @@ require ( github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/status v1.1.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-migrate/migrate/v4 v4.7.0 // indirect github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/mock v1.7.0-rc.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.1.3 // indirect @@ -178,6 +191,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 // indirect github.com/grafana/authlib v0.0.0-20250206063954-bf4600a17569 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect @@ -186,8 +200,12 @@ require ( github.com/grafana/grafana-aws-sdk v0.31.5 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect github.com/grafana/grafana-plugin-sdk-go v0.265.0 // indirect + github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // indirect + github.com/grafana/grafana/pkg/promlib v0.0.8 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/grafana/sqlds/v4 v4.1.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect @@ -208,6 +226,7 @@ require ( github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.2 // indirect @@ -232,6 +251,7 @@ require ( github.com/magefile/mage v1.15.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -253,12 +273,14 @@ require ( github.com/mithrandie/go-file/v2 v2.1.0 // indirect github.com/mithrandie/go-text v1.6.0 // indirect github.com/mithrandie/ternary v1.1.1 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/natefinch/wrap v0.2.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 // indirect @@ -287,6 +309,7 @@ require ( github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.13.2 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/prometheus v0.301.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect @@ -299,7 +322,6 @@ require ( github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartystreets/goconvey v1.6.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect @@ -317,6 +339,7 @@ require ( github.com/unknwon/com v1.0.1 // indirect github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect github.com/urfave/cli v1.22.16 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.etcd.io/bbolt v1.3.11 // indirect @@ -366,11 +389,14 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.32.1 // indirect k8s.io/component-base v0.32.1 // indirect + k8s.io/kms v0.32.1 // indirect + k8s.io/kube-aggregator v0.32.0 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum index 73e0a03e693..6d345c4151e 100644 --- a/pkg/storage/unified/apistore/go.sum +++ b/pkg/storage/unified/apistore/go.sum @@ -136,6 +136,8 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/at-wat/mqtt-go v0.19.4 h1:R2cbCU7O5PHQ38unbe1Y51ncG3KsFEJV6QeipDoqdLQ= @@ -184,6 +186,8 @@ github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -343,6 +347,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= @@ -387,6 +393,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= @@ -549,7 +557,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= @@ -805,6 +812,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/mocktools/go-smtp-mock/v2 v2.3.1 h1:wq75NDSsOy5oHo/gEQQT0fRRaYKRqr1IdkjhIPXxagM= +github.com/mocktools/go-smtp-mock/v2 v2.3.1/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -829,6 +838,8 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk= github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 h1:nZspmSkneBbtxU9TopEAE0CY+SBJLxO8LPUlw2vG4pU= github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80/go.mod h1:7tFDb+Y51LcDpn26GccuUgQXUk6t0CXZsivKjyimYX8= github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 h1:t05Ww3DxZutOqbMN+7OIuqDwXbhl32HiZGpLy26BAPc= @@ -842,8 +853,9 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -936,6 +948,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/prometheus v0.301.0 h1:0z8dgegmILivNomCd79RKvVkIols8vBGPKmcIBc7OyY= github.com/prometheus/prometheus v0.301.0/go.mod h1:BJLjWCKNfRfjp7Q48DrAjARnCi7GhfUVvUFEAWTssZM= +github.com/prometheus/sigv4 v0.1.0 h1:FgxH+m1qf9dGQ4w8Dd6VkthmpFQfGTzUeavMoQeG1LA= +github.com/prometheus/sigv4 v0.1.0/go.mod h1:doosPW9dOitMzYe2I2BN0jZqUuBrGPbXrNsTScN18iU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= @@ -975,7 +989,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= @@ -1044,7 +1057,6 @@ github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP9 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= -github.com/wk8/go-ordered-map v1.0.0 h1:BV7z+2PaK8LTSd/mWgY12HyMAo5CEgkHqbkVq2thqr8= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -1060,6 +1072,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= @@ -1155,6 +1168,7 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -1194,6 +1208,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -1239,6 +1254,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -1268,6 +1285,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -1323,6 +1341,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1381,7 +1400,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -1422,6 +1440,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -1559,6 +1578,7 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/src-d/go-errors.v1 v1.0.0 h1:cooGdZnCjYbeS1zb1s6pVAAimTdKceRrpn7aKOnNIfc= gopkg.in/src-d/go-errors.v1 v1.0.0/go.mod h1:q1cBlomlw2FnDBDNGlnh6X0jPihy+QxZfMMNxPCbdYg= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go index 330c2791cfa..d24b31a5a91 100644 --- a/pkg/storage/unified/apistore/prepare_test.go +++ b/pkg/storage/unified/apistore/prepare_test.go @@ -13,9 +13,14 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/exp/rand" "k8s.io/apimachinery/pkg/api/apitesting" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/storage" ) +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) + func TestPrepareObjectForStorage(t *testing.T) { _ = v0alpha1.AddToScheme(scheme) node, err := snowflake.NewNode(rand.Int63n(1024)) diff --git a/pkg/storage/unified/apistore/store_test.go b/pkg/storage/unified/apistore/store_test.go index 81a3b3044d4..ec1013de44a 100644 --- a/pkg/storage/unified/apistore/store_test.go +++ b/pkg/storage/unified/apistore/store_test.go @@ -3,7 +3,7 @@ // Provenance-includes-license: Apache-2.0 // Provenance-includes-copyright: The Kubernetes Authors. -package apistore +package apistore_test import ( "context" diff --git a/pkg/storage/unified/apistore/util.go b/pkg/storage/unified/apistore/util.go index fb95ed08020..e41a50b5efa 100644 --- a/pkg/storage/unified/apistore/util.go +++ b/pkg/storage/unified/apistore/util.go @@ -9,7 +9,6 @@ import ( "bytes" "fmt" "strconv" - "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -19,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -128,41 +126,3 @@ func isUnchanged(codec runtime.Codec, obj runtime.Object, newObj runtime.Object) return bytes.Equal(buf.Bytes(), newBuf.Bytes()), nil } - -func testKeyParser(val string) (*resource.ResourceKey, error) { - k, err := grafanaregistry.ParseKey(val) - if err != nil { - if strings.HasPrefix(val, "pods/") { - parts := strings.Split(val, "/") - if len(parts) == 2 { - err = nil - k = &grafanaregistry.Key{ - Resource: parts[0], // pods - Name: parts[1], - } - } else if len(parts) == 3 { - err = nil - k = &grafanaregistry.Key{ - Resource: parts[0], // pods - Namespace: parts[1], - Name: parts[2], - } - } - } - } - if err != nil { - return nil, err - } - if k.Group == "" { - k.Group = "example.apiserver.k8s.io" - } - if k.Resource == "" { - return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request")) - } - return &resource.ResourceKey{ - Namespace: k.Namespace, - Group: k.Group, - Resource: k.Resource, - Name: k.Name, - }, err -} diff --git a/pkg/storage/unified/apistore/watcher_test.go b/pkg/storage/unified/apistore/watcher_test.go index 8a8b278577f..5b0842fa985 100644 --- a/pkg/storage/unified/apistore/watcher_test.go +++ b/pkg/storage/unified/apistore/watcher_test.go @@ -3,11 +3,13 @@ // Provenance-includes-license: Apache-2.0 // Provenance-includes-copyright: The Kubernetes Authors. -package apistore +package apistore_test import ( "context" + "fmt" "os" + "strings" "testing" "time" @@ -16,6 +18,7 @@ import ( "gocloud.dev/blob/fileblob" "gocloud.dev/blob/memblob" "k8s.io/apimachinery/pkg/api/apitesting" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -28,9 +31,11 @@ import ( "k8s.io/apiserver/pkg/storage/storagebackend" "k8s.io/apiserver/pkg/storage/storagebackend/factory" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" storagetesting "github.com/grafana/grafana/pkg/apiserver/storage/testing" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" @@ -160,7 +165,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte client := resource.NewLocalResourceClient(server) config := storagebackend.NewDefaultConfig(setupOpts.prefix, setupOpts.codec) - store, destroyFunc, err := NewStorage( + store, destroyFunc, err := apistore.NewStorage( config.ForResource(setupOpts.groupResource), client, func(obj runtime.Object) (string, error) { @@ -176,7 +181,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte storage.DefaultNamespaceScopedAttr, make(map[string]storage.IndexerFunc, 0), nil, - StorageOptions{}, + apistore.StorageOptions{}, ) if err != nil { return nil, nil, nil, err @@ -371,3 +376,41 @@ func newPod() runtime.Object { func newPodList() runtime.Object { return &example.PodList{} } + +func testKeyParser(val string) (*resource.ResourceKey, error) { + k, err := grafanaregistry.ParseKey(val) + if err != nil { + if strings.HasPrefix(val, "pods/") { + parts := strings.Split(val, "/") + if len(parts) == 2 { + err = nil + k = &grafanaregistry.Key{ + Resource: parts[0], // pods + Name: parts[1], + } + } else if len(parts) == 3 { + err = nil + k = &grafanaregistry.Key{ + Resource: parts[0], // pods + Namespace: parts[1], + Name: parts[2], + } + } + } + } + if err != nil { + return nil, err + } + if k.Group == "" { + k.Group = "example.apiserver.k8s.io" + } + if k.Resource == "" { + return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request")) + } + return &resource.ResourceKey{ + Namespace: k.Namespace, + Group: k.Group, + Resource: k.Resource, + Name: k.Name, + }, err +} From 3c56e32b0c87ef303b115cc632df88d89b6bec43 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 13 Feb 2025 14:04:03 +0300 Subject: [PATCH 35/78] K8s/Utils: Find title in unstructured content (#100576) --- pkg/apimachinery/utils/meta.go | 12 ++++++++++++ pkg/apimachinery/utils/meta_test.go | 2 ++ 2 files changed, 14 insertions(+) diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index 4d39d4fcd57..5b2978533bb 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -699,6 +699,18 @@ func (m *grafanaMetaAccessor) FindTitle(defaultTitle string) string { } } + obj, ok := m.obj.(*unstructured.Unstructured) + if ok { + title, ok, _ := unstructured.NestedString(obj.Object, "spec", "title") + if ok && title != "" { + return title + } + title, ok, _ = unstructured.NestedString(obj.Object, "spec", "name") + if ok && title != "" { + return title + } + } + title := m.r.FieldByName("Title") if title.IsValid() && title.Kind() == reflect.String { return title.String() diff --git a/pkg/apimachinery/utils/meta_test.go b/pkg/apimachinery/utils/meta_test.go index 451deaa47d5..12d12cc06b5 100644 --- a/pkg/apimachinery/utils/meta_test.go +++ b/pkg/apimachinery/utils/meta_test.go @@ -194,6 +194,7 @@ func TestMetaAccessor(t *testing.T) { res.Object = map[string]any{ "spec": map[string]any{ "hello": "world", + "title": "Title", }, "status": map[string]any{ "sloth": "🦥", @@ -218,6 +219,7 @@ func TestMetaAccessor(t *testing.T) { rv, err := meta.GetResourceVersionInt64() require.NoError(t, err) require.Equal(t, int64(12345), rv) + require.Equal(t, "Title", meta.FindTitle("")) // Make sure access to spec works for Unstructured spec, err = meta.GetSpec() From 1c7a758127585029710f89e55b8d06766da06c76 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 13 Feb 2025 12:11:41 +0100 Subject: [PATCH 36/78] Frontend: Lazy load Echo Backends (#100345) feat(app): lazy load echo backends depending on config. Move lodash to sharedDependencies --- public/app/app.ts | 31 +++++++++---------- .../plugins/loader/sharedDependencies.ts | 4 ++- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/public/app/app.ts b/public/app/app.ts index 86b2386dc6c..697e909fc37 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -5,7 +5,6 @@ import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering import 'file-saver'; import 'jquery'; -import _ from 'lodash'; // eslint-disable-line lodash/import-scope import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; @@ -46,7 +45,6 @@ import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelData import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; import { setPluginPage } from '@grafana/runtime/src/components/PluginPage'; import config, { updateConfig } from 'app/core/config'; -import { arrayMove } from 'app/core/utils/arrayMove'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import getDefaultMonacoLanguages from '../lib/monaco-languages'; @@ -67,13 +65,6 @@ import { backendSrv } from './core/services/backend_srv'; import { contextSrv, RedirectToUrlKey } from './core/services/context_srv'; import { Echo } from './core/services/echo/Echo'; import { reportPerformance } from './core/services/echo/EchoSrv'; -import { PerformanceBackend } from './core/services/echo/backends/PerformanceBackend'; -import { ApplicationInsightsBackend } from './core/services/echo/backends/analytics/ApplicationInsightsBackend'; -import { BrowserConsoleBackend } from './core/services/echo/backends/analytics/BrowseConsoleBackend'; -import { GA4EchoBackend } from './core/services/echo/backends/analytics/GA4Backend'; -import { GAEchoBackend } from './core/services/echo/backends/analytics/GABackend'; -import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend'; -import { GrafanaJavascriptAgentBackend } from './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend'; import { KeybindingSrv } from './core/services/keybindingSrv'; import { startMeasure, stopMeasure } from './core/utils/metrics'; import { initDevFeatures } from './dev'; @@ -113,10 +104,6 @@ import { createSystemVariableAdapter } from './features/variables/system/adapter import { createTextBoxVariableAdapter } from './features/variables/textbox/adapter'; import { configureStore } from './store/configureStore'; -// add move to lodash for backward compatabilty with plugins -// @ts-ignore -_.move = arrayMove; - // import symlinked extensions const extensionsIndex = require.context('.', true, /extensions\/index.ts/); const extensionsExports = extensionsIndex.keys().map((key) => { @@ -139,7 +126,7 @@ export class GrafanaApp { initI18nPromise.then(({ language }) => updateConfig({ language })); setBackendSrv(backendSrv); - initEchoSrv(); + await initEchoSrv(); // This needs to be done after the `initEchoSrv` since it is being used under the hood. startMeasure('frontend_app_init'); @@ -295,7 +282,7 @@ function initExtensions() { } } -function initEchoSrv() { +async function initEchoSrv() { setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' })); window.addEventListener('load', (e) => { @@ -315,6 +302,7 @@ function initEchoSrv() { }); if (contextSrv.user.orgRole !== '') { + const { PerformanceBackend } = await import('./core/services/echo/backends/PerformanceBackend'); registerEchoBackend(new PerformanceBackend({})); } @@ -328,6 +316,10 @@ function initEchoSrv() { .filter(Boolean) .map((url) => new RegExp(`${url}.*.`)); + const { GrafanaJavascriptAgentBackend } = await import( + './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend' + ); + registerEchoBackend( new GrafanaJavascriptAgentBackend({ ...config.grafanaJavascriptAgent, @@ -346,6 +338,7 @@ function initEchoSrv() { } if (config.googleAnalyticsId) { + const { GAEchoBackend } = await import('./core/services/echo/backends/analytics/GABackend'); registerEchoBackend( new GAEchoBackend({ googleAnalyticsId: config.googleAnalyticsId, @@ -354,6 +347,7 @@ function initEchoSrv() { } if (config.googleAnalytics4Id) { + const { GA4EchoBackend } = await import('./core/services/echo/backends/analytics/GA4Backend'); registerEchoBackend( new GA4EchoBackend({ googleAnalyticsId: config.googleAnalytics4Id, @@ -363,6 +357,7 @@ function initEchoSrv() { } if (config.rudderstackWriteKey && config.rudderstackDataPlaneUrl) { + const { RudderstackBackend } = await import('./core/services/echo/backends/analytics/RudderstackBackend'); registerEchoBackend( new RudderstackBackend({ writeKey: config.rudderstackWriteKey, @@ -377,6 +372,9 @@ function initEchoSrv() { } if (config.applicationInsightsConnectionString) { + const { ApplicationInsightsBackend } = await import( + './core/services/echo/backends/analytics/ApplicationInsightsBackend' + ); registerEchoBackend( new ApplicationInsightsBackend({ connectionString: config.applicationInsightsConnectionString, @@ -386,6 +384,7 @@ function initEchoSrv() { } if (config.analyticsConsoleReporting) { + const { BrowserConsoleBackend } = await import('./core/services/echo/backends/analytics/BrowseConsoleBackend'); registerEchoBackend(new BrowserConsoleBackend()); } } @@ -395,7 +394,7 @@ function initEchoSrv() { * like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint) */ function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void { - const metric = _.first(performance.getEntriesByName(metricName)); + const metric = performance.getEntriesByName(metricName).at(0); if (metric) { const metricName = metric.name.replace(/-/g, '_'); reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000); diff --git a/public/app/features/plugins/loader/sharedDependencies.ts b/public/app/features/plugins/loader/sharedDependencies.ts index dbb8ed8692f..7059274c717 100644 --- a/public/app/features/plugins/loader/sharedDependencies.ts +++ b/public/app/features/plugins/loader/sharedDependencies.ts @@ -18,6 +18,7 @@ import { appEvents, contextSrv } from 'app/core/core'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; import impressionSrv from 'app/core/services/impression_srv'; import TimeSeries from 'app/core/time_series2'; +import { arrayMove } from 'app/core/utils/arrayMove'; import * as flatten from 'app/core/utils/flatten'; import kbn from 'app/core/utils/kbn'; import * as ticks from 'app/core/utils/ticks'; @@ -90,7 +91,8 @@ export const sharedDependenciesMap = { __useDefault: true, }, ...jQueryFlotDeps, - lodash: () => import('lodash').then((module) => ({ ...module, __useDefault: true })), + // add move to lodash for backward compatabilty with plugins + lodash: () => import('lodash').then((module) => ({ ...module, move: arrayMove, __useDefault: true })), moment: () => import('moment').then((module) => ({ ...module, __useDefault: true })), prismjs: () => import('prismjs'), react: () => import('react'), From 45a586c725162a52480bc0a8b9fba87f992daf1e Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Thu, 13 Feb 2025 13:25:21 +0100 Subject: [PATCH 37/78] feat: regen openapi --- pkg/apis/provisioning/v0alpha1/types.go | 1 + .../provisioning/v0alpha1/zz_generated.deepcopy.go | 5 +++++ .../provisioning/v0alpha1/zz_generated.openapi.go | 14 +++++++++++++- .../v0alpha1/githubrepositoryconfig.go | 11 +++++++++++ .../provisioning.grafana.app-v0alpha1.json | 8 +++++++- .../app/features/provisioning/api/endpoints.gen.ts | 4 +++- 6 files changed, 40 insertions(+), 3 deletions(-) diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index a245246335f..7583af30dc1 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -56,6 +56,7 @@ type GitHubRepositoryConfig struct { Token string `json:"token,omitempty"` // Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted. + // +listType=atomic EncryptedToken []byte `json:"encryptedToken,omitempty"` // Workflow allowed for changes to the repository. diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 92a4bc345c0..a31190ecb79 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -93,6 +93,11 @@ func (in *FileList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitHubRepositoryConfig) DeepCopyInto(out *GitHubRepositoryConfig) { *out = *in + if in.EncryptedToken != nil { + in, out := &in.EncryptedToken, &out.EncryptedToken + *out = make([]byte, len(*in)) + copy(*out, *in) + } if in.Workflows != nil { in, out := &in.Workflows, &out.Workflows *out = make([]Workflow, len(*in)) diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index ba44abc7892..93e3e4bcf76 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -255,11 +255,23 @@ func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.Ref }, "token": { SchemaProps: spec.SchemaProps{ - Description: "Token for accessing the repository.", + Description: "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.", Type: []string{"string"}, Format: "", }, }, + "encryptedToken": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.", + Type: []string{"string"}, + Format: "byte", + }, + }, "workflows": { SchemaProps: spec.SchemaProps{ Description: "Workflow allowed for changes to the repository. The order is relevant for defining the precedence of the workflows. Possible values: pull-request, branch, push.", diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go index 53f85b71fa8..3003228ef15 100644 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go +++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/githubrepositoryconfig.go @@ -14,6 +14,7 @@ type GitHubRepositoryConfigApplyConfiguration struct { URL *string `json:"url,omitempty"` Branch *string `json:"branch,omitempty"` Token *string `json:"token,omitempty"` + EncryptedToken []byte `json:"encryptedToken,omitempty"` Workflows []provisioningv0alpha1.Workflow `json:"workflows,omitempty"` GenerateDashboardPreviews *bool `json:"generateDashboardPreviews,omitempty"` } @@ -48,6 +49,16 @@ func (b *GitHubRepositoryConfigApplyConfiguration) WithToken(value string) *GitH return b } +// WithEncryptedToken adds the given value to the EncryptedToken field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the EncryptedToken field. +func (b *GitHubRepositoryConfigApplyConfiguration) WithEncryptedToken(values ...byte) *GitHubRepositoryConfigApplyConfiguration { + for i := range values { + b.EncryptedToken = append(b.EncryptedToken, values[i]) + } + return b +} + // WithWorkflows adds the given value to the Workflows field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the Workflows field. diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 0c0aab6a190..68f0273d1bf 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -2788,12 +2788,18 @@ "description": "The branch to use in the repository. By default, this is the main branch.", "type": "string" }, + "encryptedToken": { + "description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.", + "type": "string", + "format": "byte", + "x-kubernetes-list-type": "atomic" + }, "generateDashboardPreviews": { "description": "Whether we should show dashboard previews for pull requests By default, this is false (i.e. we will not create previews).", "type": "boolean" }, "token": { - "description": "Token for accessing the repository.", + "description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.", "type": "string" }, "url": { diff --git a/public/app/features/provisioning/api/endpoints.gen.ts b/public/app/features/provisioning/api/endpoints.gen.ts index 72f3f7f4a36..9a16ac019ab 100644 --- a/public/app/features/provisioning/api/endpoints.gen.ts +++ b/public/app/features/provisioning/api/endpoints.gen.ts @@ -736,9 +736,11 @@ export type JobList = { export type GitHubRepositoryConfig = { /** The branch to use in the repository. By default, this is the main branch. */ branch?: string; + /** Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted. */ + encryptedToken?: string; /** Whether we should show dashboard previews for pull requests By default, this is false (i.e. we will not create previews). */ generateDashboardPreviews?: boolean; - /** Token for accessing the repository. */ + /** Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again. */ token?: string; /** The repository URL `https://github.com/example/test`). */ url: string; From a69fac6e16906cbf29d9708d611dc09298cdbdaa Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:40:53 +0100 Subject: [PATCH 38/78] Mark grpc data source timeouts as cancelled queries (#100573) * Set up to reproduce issue locally * add check for deadline exceeded * Revert "Set up to reproduce issue locally" This reverts commit d8d9b354cab93e0e88edc739c4227cc75541b867. * Trigger build --------- Co-authored-by: Will Browne --- pkg/plugins/instrumentationutils/request_status.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugins/instrumentationutils/request_status.go b/pkg/plugins/instrumentationutils/request_status.go index 9d6cb7fba5e..5e77480ee77 100644 --- a/pkg/plugins/instrumentationutils/request_status.go +++ b/pkg/plugins/instrumentationutils/request_status.go @@ -35,7 +35,7 @@ func RequestStatusFromError(err error) RequestStatus { status = RequestStatusError if errors.Is(err, context.Canceled) { status = RequestStatusCancelled - } else if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Canceled { + } else if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Canceled || s.Code() == grpccodes.DeadlineExceeded { status = RequestStatusCancelled } } From be60ef0500a603e667598ef47bbab8204885aeea Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:10:58 +0100 Subject: [PATCH 39/78] IDToken: cache invalidation (#100592) * Make org role part of id token cache key. This way we will always sign a new token when it changes * Remove calls to remove id token --- pkg/api/http_server.go | 4 +- pkg/api/org_users.go | 6 -- pkg/api/org_users_test.go | 74 ++++++------------- pkg/services/auth/idimpl/service.go | 8 +- pkg/services/auth/idimpl/service_test.go | 31 ++++++++ .../serviceaccounts/manager/service.go | 9 --- 6 files changed, 60 insertions(+), 72 deletions(-) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 850a4ea5fd5..21f6f2dcd56 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -207,7 +207,6 @@ type HTTPServer struct { tempUserService tempUser.Service loginAttemptService loginAttempt.Service orgService org.Service - idService auth.IDService orgDeletionService org.DeletionService TeamService team.Service accesscontrolService accesscontrol.Service @@ -273,7 +272,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer, starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service, - userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall, idService auth.IDService, + userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -361,7 +360,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi tempUserService: tempUserService, loginAttemptService: loginAttemptService, orgService: orgService, - idService: idService, orgDeletionService: orgDeletionService, TeamService: teamService, navTreeService: navTreeService, diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index f19d4138bb8..1b83b4e3b77 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -7,11 +7,9 @@ import ( "net/http" "strconv" - claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/authn" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" @@ -434,10 +432,6 @@ func (hs *HTTPServer) updateOrgUserHelper(c *contextmodel.ReqContext, cmd org.Up } } - if err := hs.idService.RemoveIDToken(c.Req.Context(), &authn.Identity{ID: strconv.FormatInt(cmd.UserID, 10), Type: claims.TypeUser, OrgID: cmd.OrgID}); err != nil { - return response.Error(http.StatusInternalServerError, "Failed to invalidate the ID token cache", err) - } - if err := hs.orgService.UpdateOrgUser(c.Req.Context(), &cmd); err != nil { if errors.Is(err, org.ErrLastOrgAdmin) { return response.Error(http.StatusBadRequest, "Cannot change role so that there is no organization admin left", nil) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 324e9da2b39..59bb47330d6 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -9,12 +9,9 @@ import ( "strings" "testing" - "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/services/auth/idtest" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/dtos" @@ -205,12 +202,11 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { type testCase struct { - desc string - SkipOrgRoleSync bool - AuthEnabled bool - AuthModule string - shouldInvalidateIDToken bool - expectedCode int + desc string + SkipOrgRoleSync bool + AuthEnabled bool + AuthModule string + expectedCode int } permissions := []accesscontrol.Permission{ {Action: accesscontrol.ActionOrgUsersRead, Scope: "users:*"}, @@ -220,12 +216,11 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { } tests := []testCase{ { - desc: "should be able to change basicRole when skip_org_role_sync true", - SkipOrgRoleSync: true, - AuthEnabled: true, - AuthModule: login.LDAPAuthModule, - shouldInvalidateIDToken: true, - expectedCode: http.StatusOK, + desc: "should be able to change basicRole when skip_org_role_sync true", + SkipOrgRoleSync: true, + AuthEnabled: true, + AuthModule: login.LDAPAuthModule, + expectedCode: http.StatusOK, }, { desc: "should not be able to change basicRole when skip_org_role_sync false", @@ -242,20 +237,18 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { expectedCode: http.StatusForbidden, }, { - desc: "should be able to change basicRole with a basic Auth", - SkipOrgRoleSync: false, - AuthEnabled: false, - AuthModule: "", - shouldInvalidateIDToken: true, - expectedCode: http.StatusOK, + desc: "should be able to change basicRole with a basic Auth", + SkipOrgRoleSync: false, + AuthEnabled: false, + AuthModule: "", + expectedCode: http.StatusOK, }, { - desc: "should be able to change basicRole with a basic Auth", - SkipOrgRoleSync: true, - AuthEnabled: true, - AuthModule: "", - shouldInvalidateIDToken: true, - expectedCode: http.StatusOK, + desc: "should be able to change basicRole with a basic Auth", + SkipOrgRoleSync: true, + AuthEnabled: true, + AuthModule: "", + expectedCode: http.StatusOK, }, } @@ -286,11 +279,6 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { } hs.userService = &usertest.FakeUserService{ExpectedSignedInUser: userWithPermissions} hs.orgService = &orgtest.FakeOrgService{} - idService := &idtest.MockService{} - if tt.shouldInvalidateIDToken { - idService.On("RemoveIDToken", mock.Anything, mock.Anything).Return(nil) - } - hs.idService = idService hs.SocialService = &socialtest.FakeSocialService{ ExpectedAuthInfoProvider: &social.OAuthInfo{Enabled: tt.AuthEnabled, SkipOrgRoleSync: tt.SkipOrgRoleSync}, } @@ -627,7 +615,6 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { ExpectedUser: &user.User{}, ExpectedSignedInUser: userWithPermissions(1, tt.permissions), } - hs.idService = &idtest.FakeService{} hs.accesscontrolService = &actest.FakeService{} }) @@ -650,24 +637,16 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { name string role org.RoleType permissions []accesscontrol.Permission - setup func(*testing.T, *idtest.MockService) input string expectedCode int } tests := []testCase{ { - name: "user with permissions can update org role", - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: "users:*"}}, - role: org.RoleAdmin, - input: `{"role": "Viewer"}`, - setup: func(t *testing.T, idService *idtest.MockService) { - idService.On("RemoveIDToken", mock.Anything, mock.MatchedBy(func(id *authn.Identity) bool { - return id.GetIdentityType() == types.TypeUser && - id.GetID() == "user:1" && - id.GetOrgID() == int64(1) - })).Return(nil) - }, + name: "user with permissions can update org role", + permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: "users:*"}}, + role: org.RoleAdmin, + input: `{"role": "Viewer"}`, expectedCode: http.StatusOK, }, { @@ -694,11 +673,6 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { AuthModule: "", }, } - idService := &idtest.MockService{} - if tt.setup != nil { - tt.setup(t, idService) - } - hs.idService = idService hs.accesscontrolService = &actest.FakeService{} hs.userService = &usertest.FakeUserService{ ExpectedUser: &user.User{}, diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go index a4b89204d7b..5dec411fdee 100644 --- a/pkg/services/auth/idimpl/service.go +++ b/pkg/services/auth/idimpl/service.go @@ -63,7 +63,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri s.metrics.tokenSigningDurationHistogram.Observe(time.Since(t).Seconds()) }(time.Now()) - cacheKey := prefixCacheKey(id.GetCacheKey()) + cacheKey := getCacheKey(id) type resultType struct { token string @@ -140,7 +140,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri } func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) error { - return s.cache.Delete(ctx, prefixCacheKey(id.GetCacheKey())) + return s.cache.Delete(ctx, getCacheKey(id)) } func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error { @@ -181,8 +181,8 @@ func getAudience(orgID int64) jwt.Audience { return jwt.Audience{fmt.Sprintf("org:%d", orgID)} } -func prefixCacheKey(key string) string { - return fmt.Sprintf("%s-%s", cachePrefix, key) +func getCacheKey(ident identity.Requester) string { + return cachePrefix + ident.GetCacheKey() + string(ident.GetOrgRole()) } func shouldLogErr(err error) bool { diff --git a/pkg/services/auth/idimpl/service_test.go b/pkg/services/auth/idimpl/service_test.go index 0f3814968e3..bb7ee510e55 100644 --- a/pkg/services/auth/idimpl/service_test.go +++ b/pkg/services/auth/idimpl/service_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" ) @@ -101,4 +102,34 @@ func TestService_SignIdentity(t *testing.T) { assert.Equal(t, claims.TypeUser, gotClaims.Rest.Type) assert.Equal(t, "edpu3nnt61se8e", gotClaims.Rest.Identifier) }) + + t.Run("should sign new token if org role has changed", func(t *testing.T) { + s := ProvideService( + setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(), + &authntest.FakeService{}, nil, + ) + + ident := &authn.Identity{ + ID: "1", + Type: claims.TypeUser, + AuthenticatedBy: login.AzureADAuthModule, + Login: "U1", + UID: "edpu3nnt61se8e", + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, + } + + first, _, err := s.SignIdentity(context.Background(), ident) + require.NoError(t, err) + + second, _, err := s.SignIdentity(context.Background(), ident) + require.NoError(t, err) + + assert.Equal(t, first, second) + + ident.OrgRoles[1] = org.RoleEditor + third, _, err := s.SignIdentity(context.Background(), ident) + require.NoError(t, err) + assert.NotEqual(t, first, third) + }) } diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index f53e7b72d9b..cde680f616d 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -17,8 +17,6 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/auth" - "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" @@ -44,7 +42,6 @@ type ServiceAccountsService struct { secretScanService secretscan.Checker orgService org.Service serverLock *serverlock.ServerLockService - idService auth.IDService secretScanEnabled bool secretScanInterval time.Duration @@ -61,7 +58,6 @@ func ProvideServiceAccountsService( acService accesscontrol.Service, permissions accesscontrol.ServiceAccountPermissionsService, serverLockService *serverlock.ServerLockService, - idService auth.IDService, ) (*ServiceAccountsService, error) { serviceAccountsStore := database.ProvideServiceAccountsStore( cfg, @@ -81,7 +77,6 @@ func ProvideServiceAccountsService( backgroundLog: log.New("serviceaccounts.background"), orgService: orgService, serverLock: serverLockService, - idService: idService, } if err := RegisterRoles(acService); err != nil { @@ -271,10 +266,6 @@ func (sa *ServiceAccountsService) UpdateServiceAccount(ctx context.Context, orgI return nil, err } - if err := sa.idService.RemoveIDToken(ctx, &authn.Identity{ID: strconv.FormatInt(serviceAccountID, 10), Type: claims.TypeServiceAccount, OrgID: orgID}); err != nil { - return nil, err - } - return sa.store.UpdateServiceAccount(ctx, orgID, serviceAccountID, saForm) } From 45ebb0b1f93e2673ec8a4c9efc4645ff56e71bf4 Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Thu, 13 Feb 2025 14:13:48 +0100 Subject: [PATCH 40/78] Provisioning: Retain old token in config (#100597) fix: retain old token in config --- public/app/features/provisioning/ConfigForm.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx index feee5b851aa..0808dc5c090 100644 --- a/public/app/features/provisioning/ConfigForm.tsx +++ b/public/app/features/provisioning/ConfigForm.tsx @@ -94,8 +94,13 @@ export function ConfigForm({ data }: ConfigFormProps) { } }, [request.isSuccess, reset, getValues, navigate]); - const onSubmit = (data: RepositoryFormData) => { - const spec = dataToSpec(data); + const onSubmit = (form: RepositoryFormData) => { + const spec = dataToSpec(form); + if (spec.github) { + spec.github.token = form.token || data?.spec?.github?.token; + // If we're still keeping this as GitHub, persist the old token. If we set a new one, it'll be re-encrypted into here. + spec.github.encryptedToken = data?.spec?.github?.encryptedToken; + } submitData(spec); }; From a58564a35efe8c05a21d8190b283af5bc0979d2a Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:24:59 +0100 Subject: [PATCH 41/78] Unified Storage: Register metrics (#100600) use seperate once struct --- pkg/storage/unified/resource/bleve_index_metrics.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go index 0cb52455cff..a15f36be543 100644 --- a/pkg/storage/unified/resource/bleve_index_metrics.go +++ b/pkg/storage/unified/resource/bleve_index_metrics.go @@ -12,6 +12,7 @@ import ( var ( onceIndex sync.Once + onceSprinkles sync.Once IndexMetrics *BleveIndexMetrics SprinklesIndexMetrics *SprinklesMetrics ) @@ -36,7 +37,7 @@ type SprinklesMetrics struct { var IndexCreationBuckets = []float64{1, 5, 10, 25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000} func NewSprinklesMetrics() *SprinklesMetrics { - onceIndex.Do(func() { + onceSprinkles.Do(func() { SprinklesIndexMetrics = &SprinklesMetrics{ SprinklesLatency: prometheus.NewHistogram(prometheus.HistogramOpts{ Namespace: "index_server", From afe8b08a48acc695e78cc3c5d05b5ba45ad9ffad Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:03:54 +0100 Subject: [PATCH 42/78] MultiCombobox: Fix labels disappearing on selected items when filtering (#100602) * Fix label disappearing on filtering * Remove only from test * Fix custom value test --- .../components/Combobox/MultiCombobox.test.tsx | 17 +++++++++++++++-- .../src/components/Combobox/MultiCombobox.tsx | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index bee10de6783..5be7bc09098 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -156,8 +156,8 @@ describe('MultiCombobox', () => { await user.type(input, 'D'); await user.keyboard('{arrowdown}{enter}'); expect(onChange).toHaveBeenCalledWith([ - { value: 'a' }, - { value: 'c' }, + { label: 'A', value: 'a' }, + { label: 'C', value: 'c' }, { label: 'D', value: 'D', description: 'Use custom value' }, ]); }); @@ -235,6 +235,19 @@ describe('MultiCombobox', () => { await user.click(await screen.findByRole('option', { name: 'All' })); expect(onChange).toHaveBeenCalledWith([]); }); + + it('should keep label names on selected items when searching', async () => { + const options = [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]; + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'b'); + expect(screen.getByText('A')).toBeInTheDocument(); + }); }); describe('async', () => { diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 9a90dfd4588..7d18074910a 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -79,8 +79,8 @@ export const MultiCombobox = (props: MultiComboboxPro return []; } - return getSelectedItemsFromValue(value, baseOptions); - }, [value, baseOptions]); + return getSelectedItemsFromValue(value, typeof props.options !== 'function' ? props.options : baseOptions); + }, [value, props.options, baseOptions]); const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti( selectedItems, From 06de4c004113bd02f5d4f8bb90b9180d6dcf3c33 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 13 Feb 2025 17:13:32 +0300 Subject: [PATCH 43/78] =?UTF-8?q?Provisioning:=20legacy=20=E2=86=92=20git?= =?UTF-8?q?=20=E2=86=92=20unified=20(#100481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .betterer.results | 15 +- pkg/apimachinery/identity/context.go | 3 + pkg/apis/provisioning/v0alpha1/jobs.go | 1 + pkg/apis/provisioning/v0alpha1/settings.go | 5 + .../v0alpha1/zz_generated.openapi.go | 13 ++ pkg/registry/apis/dashboard/legacy/migrate.go | 16 +- .../provisioning/controller/repository.go | 6 +- .../apis/provisioning/jobs/export/folders.go | 142 +++++++++++++--- .../apis/provisioning/jobs/export/job.go | 151 +++++------------- .../provisioning/jobs/export/resources.go | 132 +++++++++++++++ .../apis/provisioning/jobs/export/users.go | 60 +++++++ .../apis/provisioning/jobs/export/worker.go | 65 +++++--- .../apis/provisioning/jobs/progress.go | 2 +- .../apis/provisioning/jobs/sync/legacy.go | 66 ++++++++ .../apis/provisioning/jobs/sync/worker.go | 37 ++++- pkg/registry/apis/provisioning/register.go | 30 +++- .../provisioning/repository/go-git/wrapper.go | 40 +++-- .../repository/go-git/wrapper_test.go | 1 + .../apis/provisioning/resources/tree.go | 43 ++--- pkg/registry/apis/provisioning/routes.go | 4 +- pkg/server/wire.go | 2 + .../legacysql/dualwrite/managed_mode3.go | 64 ++++---- pkg/storage/legacysql/dualwrite/service.go | 9 +- .../legacysql/dualwrite/storage_sql_mig.go | 11 +- pkg/storage/legacysql/dualwrite/types.go | 2 +- pkg/storage/legacysql/dualwrite/utils.go | 18 +++ .../provisioning.grafana.app-v0alpha1.json | 8 + .../app/features/provisioning/ConfigForm.tsx | 2 +- .../provisioning/ExportToRepository.tsx | 13 +- .../app/features/provisioning/RecentJobs.tsx | 2 +- .../provisioning/RepositoryListPage.tsx | 9 +- .../features/provisioning/SetupWarnings.tsx | 6 - .../provisioning/api/endpoints.gen.ts | 3 + 33 files changed, 708 insertions(+), 273 deletions(-) create mode 100644 pkg/registry/apis/provisioning/jobs/export/resources.go create mode 100644 pkg/registry/apis/provisioning/jobs/export/users.go create mode 100644 pkg/registry/apis/provisioning/jobs/sync/legacy.go create mode 100644 pkg/storage/legacysql/dualwrite/utils.go diff --git a/.betterer.results b/.betterer.results index df376f544fa..711fa68a381 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5752,10 +5752,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "6"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], "public/app/features/provisioning/FileHistoryPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], @@ -5782,8 +5780,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] ], "public/app/features/provisioning/RepositoryHealth.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], @@ -5793,12 +5790,14 @@ exports[`better eslint`] = { ], "public/app/features/provisioning/RepositoryListPage.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "8"] ], "public/app/features/provisioning/RepositoryOverview.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 598abbeaae2..b5a4484936a 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -111,6 +111,9 @@ var serviceIdentityPermissions = getWildcardPermissions( "datasources:delete", "alert.provisioning:write", "alert.provisioning.secrets:read", + "users:read", // accesscontrol.ActionUsersRead, + "org.users:read", // accesscontrol.ActionOrgUsersRead, + "teams:read", // accesscontrol.ActionTeamsRead, ) func IsServiceIdentity(ctx context.Context) bool { diff --git a/pkg/apis/provisioning/v0alpha1/jobs.go b/pkg/apis/provisioning/v0alpha1/jobs.go index a4e01d98743..86643858a9f 100644 --- a/pkg/apis/provisioning/v0alpha1/jobs.go +++ b/pkg/apis/provisioning/v0alpha1/jobs.go @@ -136,6 +136,7 @@ func (in *JobStatus) ToSyncStatus(jobId string) SyncStatus { type JobResourceSummary struct { Group string `json:"group,omitempty"` Resource string `json:"resource,omitempty"` + Total int64 `json:"total,omitempty"` // the count (if known) Create int64 `json:"create,omitempty"` Update int64 `json:"update,omitempty"` diff --git a/pkg/apis/provisioning/v0alpha1/settings.go b/pkg/apis/provisioning/v0alpha1/settings.go index 65ad68fd7dc..63d202f7ee1 100644 --- a/pkg/apis/provisioning/v0alpha1/settings.go +++ b/pkg/apis/provisioning/v0alpha1/settings.go @@ -9,6 +9,11 @@ import ( type RepositoryViewList struct { metav1.TypeMeta `json:",inline"` + // The backend is using legacy storage + // FIXME: Not sure where this should be exposed... but we need it somewhere + // The UI should force the onboarding workflow when this is true + LegacyStorage bool `json:"legacyStorage,omitempty"` + // +mapType=atomic Items []RepositoryView `json:"items"` } diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 93e3e4bcf76..fd73980698f 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -569,6 +569,12 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen Format: "", }, }, + "total": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Format: "int64", + }, + }, "create": { SchemaProps: spec.SchemaProps{ Type: []string{"integer"}, @@ -1156,6 +1162,13 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryViewList(ref common.Referen Format: "", }, }, + "legacyStorage": { + SchemaProps: spec.SchemaProps{ + Description: "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true", + Type: []string{"boolean"}, + Format: "", + }, + }, "items": { VendorExtensible: spec.VendorExtensible{ Extensions: spec.Extensions{ diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index c1236ef4f2d..5a5cb951aa5 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -14,7 +14,10 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" dashboard "github.com/grafana/grafana/pkg/apis/dashboard" folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -22,7 +25,6 @@ import ( type MigrateOptions struct { Namespace string Store resource.BatchStoreClient - Writer resource.BatchResourceWriter LargeObjects apistore.LargeObjectSupport BlobStore resource.BlobStoreClient Resources []schema.GroupResource @@ -36,6 +38,15 @@ type LegacyMigrator interface { Migrate(ctx context.Context, opts MigrateOptions) (*resource.BatchResponse, error) } +// This can migrate Folders, Dashboards and LibraryPanels +func ProvideLegacyMigrator( + sql db.DB, // direct access to tables + provisioning provisioning.ProvisioningService, // only needed for dashboard settings +) LegacyMigrator { + dbp := legacysql.NewDatabaseProvider(sql) + return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, false) +} + type BlobStoreInfo struct { Count int64 Size int64 @@ -49,6 +60,9 @@ func (a *dashboardSqlAccess) Migrate(ctx context.Context, opts MigrateOptions) ( if err != nil { return nil, err } + if opts.Progress == nil { + opts.Progress = func(count int, msg string) {} // noop + } // Migrate everything if len(opts.Resources) < 1 { diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 2cbbd48e5af..08a5f7a6920 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -346,8 +346,10 @@ func (rc *RepositoryController) process(item *queueItem) error { } sync = &provisioning.SyncJobOptions{} case shouldResync: - logger.Info("handle repository resync") - sync = &provisioning.SyncJobOptions{Incremental: true} + if obj.Spec.Sync.Enabled { + logger.Info("handle repository resync") + sync = &provisioning.SyncJobOptions{Incremental: true} + } default: logger.Info("handle unknown repository situation") } diff --git a/pkg/registry/apis/provisioning/jobs/export/folders.go b/pkg/registry/apis/provisioning/jobs/export/folders.go index 0d2b640178f..c47e667e130 100644 --- a/pkg/registry/apis/provisioning/jobs/export/folders.go +++ b/pkg/registry/apis/provisioning/jobs/export/folders.go @@ -1,39 +1,141 @@ package export import ( + "context" + "errors" "fmt" - "golang.org/x/net/context" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/dynamic" + "k8s.io/apimachinery/pkg/runtime/schema" - apiutils "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" + folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/grafana/grafana/pkg/storage/unified/parquet" + "github.com/grafana/grafana/pkg/storage/unified/resource" ) -func readFolders(ctx context.Context, client dynamic.ResourceInterface, skip string) (*resources.FolderTree, error) { - // TODO: handle pagination - rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000}) +var ( + _ resource.BatchResourceWriter = (*folderReader)(nil) +) + +type folderReader struct { + tree *resources.FolderTree + targetRepoName string + summary *provisioning.JobResourceSummary +} + +// Close implements resource.BatchResourceWriter. +func (f *folderReader) Close() error { + return nil +} + +// CloseWithResults implements resource.BatchResourceWriter. +func (f *folderReader) CloseWithResults() (*resource.BatchResponse, error) { + return &resource.BatchResponse{}, nil +} + +// Write implements resource.BatchResourceWriter. +func (f *folderReader) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error { + item := &unstructured.Unstructured{} + err := item.UnmarshalJSON(value) if err != nil { - return nil, fmt.Errorf("failed to list folders: %w", err) + return err } - if rawList.GetContinue() != "" { - return nil, fmt.Errorf("unable to list all folders in one request: %s", rawList.GetContinue()) + err = f.tree.AddUnstructured(item, f.targetRepoName) + if err != nil { + f.summary.Errors = append(f.summary.Errors, err.Error()) + } + return nil +} + +func (r *exportJob) loadFolders(ctx context.Context) error { + logger := r.logger + status := r.jobStatus + status.Message = "reading folder tree" + r.maybeNotify(ctx) + + summary := r.getSummary(schema.GroupResource{ + Group: folders.GROUP, + Resource: folders.RESOURCE, + }) + + reader := &folderReader{ + tree: resources.NewEmptyFolderTree(), + targetRepoName: r.target.Config().Name, + summary: summary, } - // filter out the folders we already own - rawFolders := make([]unstructured.Unstructured, 0, len(rawList.Items)) - for _, f := range rawList.Items { - repoName := f.GetAnnotations()[apiutils.AnnoKeyRepoName] - if repoName == skip { - logger.Info("skip as folder is already in repository", "folder", f.GetName()) - continue + if r.legacy != nil { + _, err := r.legacy.Migrate(ctx, legacy.MigrateOptions{ + Namespace: r.namespace, + Resources: []schema.GroupResource{{ + Group: folders.GROUP, + Resource: folders.RESOURCE, + }}, + Store: parquet.NewBatchResourceWriterClient(reader), + }) + if err != nil { + return fmt.Errorf("unable to read folders from legacy storage %w", err) + } + } else { + client := r.client.Resource(schema.GroupVersionResource{ + Group: folders.GROUP, + Version: folders.VERSION, + Resource: folders.RESOURCE, + }) + + rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000}) + if err != nil { + return fmt.Errorf("failed to list folders: %w", err) + } + if rawList.GetContinue() != "" { + return fmt.Errorf("unable to list all folders in one request: %s", rawList.GetContinue()) + } + for _, item := range rawList.Items { + err = reader.tree.AddUnstructured(&item, reader.targetRepoName) + if err != nil { + summary.Errors = append(summary.Errors, err.Error()) + } + } + } + + // first create folders + // NOTE: this is required so that empty folders exist when finished + status.Message = "writing folders" + err := reader.tree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error { + p := folder.Path + "/" + if r.prefix != "" { + p = r.prefix + "/" + p + } + logger := logger.With("path", p) + + _, err := r.target.Read(ctx, p, r.ref) + if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { + logger.Error("failed to check if folder exists before writing", "error", err) + return fmt.Errorf("failed to check if folder exists before writing: %w", err) + } else if err == nil { + logger.Info("folder already exists") + summary.Noop++ + return nil } - rawFolders = append(rawFolders, f) + // Create with an empty body will make a folder (or .keep file if unsupported) + if err := r.target.Create(ctx, p, r.ref, nil, "export folder `"+p+"`"); err != nil { + logger.Error("failed to write a folder in repository", "error", err) + return fmt.Errorf("failed to write folder in repo: %w", err) + } + summary.Create++ + logger.Debug("successfully exported folder") + return nil + }) + if err != nil { + return fmt.Errorf("failed to write folders: %w", err) } - - return resources.NewFolderTreeFromUnstructure(ctx, rawFolders), nil + r.foldersTree = reader.tree + return nil } diff --git a/pkg/registry/apis/provisioning/jobs/export/job.go b/pkg/registry/apis/provisioning/jobs/export/job.go index 168406578f4..abe1e2cad54 100644 --- a/pkg/registry/apis/provisioning/jobs/export/job.go +++ b/pkg/registry/apis/provisioning/jobs/export/job.go @@ -3,21 +3,17 @@ package export import ( "context" "encoding/json" - "errors" "fmt" "time" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/utils" - folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" @@ -26,19 +22,22 @@ import ( // ExportJob holds all context for a running job type exportJob struct { - logger logging.Logger - client *resources.DynamicClient // Read from - target repository.Repository // Write to + logger logging.Logger + client *resources.DynamicClient // Read from + target repository.Repository // Write to + legacy legacy.LegacyMigrator + namespace string progress jobs.ProgressFn progressInterval time.Duration progressLast time.Time foldersTree *resources.FolderTree + userInfo map[string]repository.CommitSignature prefix string // from options (now clean+safe) ref string // from options (only git) keepIdentifier bool - addAuthorInfo bool + withHistory bool jobStatus *provisioning.JobStatus summary map[string]*provisioning.JobResourceSummary @@ -55,17 +54,18 @@ func newExportJob(ctx context.Context, prefix = safepath.Clean(prefix) } return &exportJob{ + namespace: target.Config().Namespace, target: target, client: client, logger: logging.FromContext(ctx), progress: progress, progressLast: time.Now(), - progressInterval: time.Second * 10, + progressInterval: time.Second * 5, prefix: prefix, ref: options.Branch, keepIdentifier: options.Identifier, - addAuthorInfo: options.History, + withHistory: options.History, jobStatus: &provisioning.JobStatus{ State: provisioning.JobStateWorking, @@ -77,6 +77,7 @@ func newExportJob(ctx context.Context, // Send progress messages to any listeners func (r *exportJob) maybeNotify(ctx context.Context) { if time.Since(r.progressLast) > r.progressInterval { + r.progressLast = time.Now() err := r.progress(ctx, *r.jobStatus) if err != nil { r.logger.Warn("unable to send progress", "err", err) @@ -98,86 +99,6 @@ func (r *exportJob) getSummary(gr schema.GroupResource) *provisioning.JobResourc return summary } -func (r *exportJob) loadFolders(ctx context.Context) error { - logger := r.logger - targetRepoName := r.target.Config().Name - status := r.jobStatus - status.Message = "reading folder tree" - foldersTree, err := readFolders(ctx, r.client.Resource(schema.GroupVersionResource{ - Group: folders.GROUP, - Version: folders.VERSION, - Resource: folders.RESOURCE, - }), targetRepoName) - - summary := r.getSummary(schema.GroupResource{ - Group: folders.GROUP, - Resource: folders.RESOURCE, - }) - - // first create folders - // TODO! this should not be necessary if writing to a path also makes the parents - status.Message = "writing folders" - err = foldersTree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error { - p := folder.Path + "/" - if r.prefix != "" { - p = r.prefix + "/" + p - } - logger := logger.With("path", p) - - _, err = r.target.Read(ctx, p, r.ref) - if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { - logger.Error("failed to check if folder exists before writing", "error", err) - return fmt.Errorf("failed to check if folder exists before writing: %w", err) - } else if err == nil { - logger.Info("folder already exists") - summary.Noop++ - return nil - } - - // Create with an empty body will make a folder (or .keep file if unsupported) - if err := r.target.Create(ctx, p, r.ref, nil, "export folder `"+p+"`"); err != nil { - logger.Error("failed to write a folder in repository", "error", err) - return fmt.Errorf("failed to write folder in repo: %w", err) - } - summary.Create++ - logger.Debug("successfully exported folder") - return nil - }) - if err != nil { - return fmt.Errorf("failed to write folders: %w", err) - } - r.foldersTree = foldersTree - return nil -} - -func (r *exportJob) export(ctx context.Context, kind schema.GroupVersionResource) error { - r.jobStatus.Message = "Exporting " + kind.Resource + "..." - r.maybeNotify(ctx) - client := r.client.Resource(kind) - summary := r.getSummary(kind.GroupResource()) - - continueToken := "" - for { - list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken}) - if err != nil { - return fmt.Errorf("error executing list: %w", err) - } - - for _, item := range list.Items { - if err = r.add(ctx, summary, &item); err != nil { - return fmt.Errorf("error adding value: %w", err) - } - } - - continueToken = list.GetContinue() - if continueToken == "" { - break - } - } - - return nil -} - func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSummary, obj *unstructured.Unstructured) error { if err := ctx.Err(); err != nil { return err @@ -194,7 +115,7 @@ func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSu if commitMessage == "" { g := item.GetGeneration() if g > 0 { - commitMessage = fmt.Sprintf("Generation: %d, ResourceVersion: %s", g, item.GetResourceVersion()) + commitMessage = fmt.Sprintf("Generation: %d", g) } else { commitMessage = "exported from grafana" } @@ -213,14 +134,21 @@ func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSu } folder := item.GetFolder() + // Add the author in context (if available) + ctx = r.withAuthorSignature(ctx, item) + // Get the absolute path of the folder fid, ok := r.foldersTree.DirPath(folder, "") if !ok { - logger.Error("folder of item was not in tree of repository") - return fmt.Errorf("folder of item was not in tree of repository") + fid = resources.Folder{ + Path: "__folder_not_found/" + slugify.Slugify(folder), + } + r.logger.Error("folder of item was not in tree of repository") } + // Clear the metadata delete(obj.Object, "metadata") + if r.keepIdentifier { item.SetName(name) // keep the identifier in the metadata } @@ -244,14 +172,11 @@ func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSu } } - // Add the author in context (if available) - ctx = r.withAuthorSignature(ctx, item) - // Write the file err = r.target.Write(ctx, fileName, r.ref, body, commitMessage) if err != nil { summary.Error++ - logger.Error("failed to write a file in repository", "error", err) + r.logger.Error("failed to write a file in repository", "error", err) if len(summary.Errors) < 20 { summary.Errors = append(summary.Errors, fmt.Sprintf("error writing: %s", fileName)) } @@ -263,24 +188,26 @@ func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSu } func (r *exportJob) withAuthorSignature(ctx context.Context, item utils.GrafanaMetaAccessor) context.Context { - if !r.addAuthorInfo { + if r.userInfo == nil { return ctx } + id := item.GetUpdatedBy() + if id == "" { + id = item.GetCreatedBy() + } + if id == "" { + id = "grafana" + } - sig := repository.CommitSignature{ - Name: item.GetUpdatedBy(), - When: item.GetCreationTimestamp().Time, + sig := r.userInfo[id] // lookup + if sig.Name == "" && sig.Email == "" { + sig.Name = id } - if sig.Name == "" { - sig.Name = item.GetCreatedBy() - } - if sig.Name == "" { - return ctx // no user info - } - // TODO: convert internal id to name+email - updated, _ := item.GetUpdatedTimestamp() - if updated != nil { - sig.When = *updated + t, err := item.GetUpdatedTimestamp() + if err == nil && t != nil { + sig.When = *t + } else { + sig.When = item.GetCreationTimestamp().Time } return repository.WithAuthorSignature(ctx, sig) } diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go new file mode 100644 index 00000000000..a332b6ece41 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -0,0 +1,132 @@ +package export + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana-app-sdk/logging" + dashboards "github.com/grafana/grafana/pkg/apis/dashboard" + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/storage/unified/parquet" + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +var ( + _ resource.BatchResourceWriter = (*resourceReader)(nil) +) + +type resourceReader struct { + job *exportJob + summary *provisioning.JobResourceSummary + logger logging.Logger +} + +// Close implements resource.BatchResourceWriter. +func (f *resourceReader) Close() error { + return nil +} + +// CloseWithResults implements resource.BatchResourceWriter. +func (f *resourceReader) CloseWithResults() (*resource.BatchResponse, error) { + return &resource.BatchResponse{}, nil +} + +// Write implements resource.BatchResourceWriter. +func (f *resourceReader) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error { + item := &unstructured.Unstructured{} + err := item.UnmarshalJSON(value) + if err != nil { + return err + } + err = f.job.add(ctx, f.summary, item) + if err != nil { + f.logger.Warn("error adding from legacy", "name", key.Name, "err", err) + f.summary.Errors = append(f.summary.Errors, fmt.Sprintf("%s: %s", key.Name, err.Error())) + if len(f.summary.Errors) > 50 { + return err + } + } + return nil +} + +func (r *exportJob) loadResources(ctx context.Context) error { + kinds := []schema.GroupVersionResource{{ + Group: dashboards.GROUP, + Resource: dashboards.DASHBOARD_RESOURCE, + Version: "v1alpha1", + }} + + for _, kind := range kinds { + r.jobStatus.Message = "Exporting " + kind.Resource + "..." + if r.legacy != nil { + gr := kind.GroupResource() + reader := &resourceReader{ + summary: r.getSummary(gr), + job: r, + logger: r.logger, + } + opts := legacy.MigrateOptions{ + Namespace: r.namespace, + WithHistory: r.withHistory, + Resources: []schema.GroupResource{gr}, + Store: parquet.NewBatchResourceWriterClient(reader), + OnlyCount: true, // first get the count + } + stats, err := r.legacy.Migrate(ctx, opts) + if err != nil { + return fmt.Errorf("unable to count legacy items %w", err) + } + if len(stats.Summary) > 0 { + count := stats.Summary[0].Count + history := stats.Summary[0].History + if history > count { + count = history // the number of items we will process + } + reader.summary.Total = count + } + + opts.OnlyCount = false // this time actually write + _, err = r.legacy.Migrate(ctx, opts) + if err != nil { + return fmt.Errorf("error running legacy migrate %s %w", kind.Resource, err) + } + } + + if err := r.loadResourcesFromAPIServer(ctx, kind); err != nil { + return fmt.Errorf("error loading %s %w", kind.Resource, err) + } + } + return nil +} + +func (r *exportJob) loadResourcesFromAPIServer(ctx context.Context, kind schema.GroupVersionResource) error { + r.maybeNotify(ctx) + client := r.client.Resource(kind) + summary := r.getSummary(kind.GroupResource()) + + continueToken := "" + for { + list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken}) + if err != nil { + return fmt.Errorf("error executing list: %w", err) + } + + for _, item := range list.Items { + if err = r.add(ctx, summary, &item); err != nil { + return fmt.Errorf("error adding value: %w", err) + } + } + + continueToken = list.GetContinue() + if continueToken == "" { + break + } + } + + return nil +} diff --git a/pkg/registry/apis/provisioning/jobs/export/users.go b/pkg/registry/apis/provisioning/jobs/export/users.go new file mode 100644 index 00000000000..32548a172c4 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/export/users.go @@ -0,0 +1,60 @@ +package export + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" +) + +func (r *exportJob) loadUsers(ctx context.Context) error { + status := r.jobStatus + status.Message = "reading user info" + r.maybeNotify(ctx) + + client := r.client.Resource(schema.GroupVersionResource{ + Group: iam.GROUP, + Version: iam.VERSION, + Resource: iam.UserResourceInfo.GroupResource().Resource, + }) + + rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000}) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + if rawList.GetContinue() != "" { + return fmt.Errorf("unable to list all users in one request: %s", rawList.GetContinue()) + } + + var ok bool + r.userInfo = make(map[string]repository.CommitSignature) + for _, item := range rawList.Items { + sig := repository.CommitSignature{} + sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login") + if !ok || err != nil { + continue + } + sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email") + if !ok || err != nil { + continue + } + + if sig.Name == sig.Email { + if sig.Name == "" { + sig.Name = item.GetName() + } else if strings.Contains(sig.Email, "@") { + sig.Email = "" // don't use the same value for name+email + } + } + + r.userInfo["user:"+item.GetName()] = sig + } + + return nil +} diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go index 4478ac0ab0c..bc27197b907 100644 --- a/pkg/registry/apis/provisioning/jobs/export/worker.go +++ b/pkg/registry/apis/provisioning/jobs/export/worker.go @@ -5,23 +5,45 @@ import ( "fmt" "os" - "k8s.io/apimachinery/pkg/runtime/schema" - - dashboards "github.com/grafana/grafana/pkg/apis/dashboard" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) type ExportWorker struct { - clients *resources.ClientFactory + // Tempdir for repo clones clonedir string + + // When exporting from apiservers + clients *resources.ClientFactory + + // Check where values are currently saved + storageStatus dualwrite.Service + + // Support reading from history + legacyMigrator legacy.LegacyMigrator + + secrets secrets.Service } -func NewExportWorker(clients *resources.ClientFactory, clonedir string) *ExportWorker { - return &ExportWorker{clients, clonedir} +func NewExportWorker(clients *resources.ClientFactory, + legacyMigrator legacy.LegacyMigrator, + storageStatus dualwrite.Service, + secrets secrets.Service, + clonedir string, +) *ExportWorker { + return &ExportWorker{ + clonedir, + clients, + storageStatus, + legacyMigrator, + secrets, + } } func (r *ExportWorker) IsSupported(ctx context.Context, job provisioning.Job) bool { @@ -51,7 +73,7 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, buffered, err = gogit.Clone(ctx, repo.Config(), gogit.GoGitCloneOptions{ Root: r.clonedir, SingleCommitBeforePush: !options.History, - }, os.Stdout) + }, r.secrets, os.Stdout) if err != nil { return &provisioning.JobStatus{ State: provisioning.JobStateError, @@ -59,7 +81,7 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, }, nil } - // New empty branch + // New empty branch (same on main???) if options.Branch != "" { _, err := buffered.NewEmptyBranch(ctx, options.Branch) if err != nil { @@ -75,27 +97,32 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, dynamicClient, _, err := r.clients.New(repo.Config().Namespace) if err != nil { - return nil, fmt.Errorf("namespace mismatch") + return nil, fmt.Errorf("error getting client %w", err) } worker := newExportJob(ctx, repo, *options, dynamicClient, progress) + if options.History { + err = worker.loadUsers(ctx) + if err != nil { + return nil, fmt.Errorf("error loading users %w", err) + } + } + + // Read from legacy if not yet using unified storage + if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) { + worker.legacy = r.legacyMigrator + } + // Load and write all folders err = worker.loadFolders(ctx) if err != nil { return worker.jobStatus, err } - kinds := []schema.GroupVersionResource{{ - Group: dashboards.GROUP, - Version: "v1alpha1", - Resource: dashboards.DASHBOARD_RESOURCE, - }} - for _, kind := range kinds { - err = worker.export(ctx, kind) - if err != nil { - return worker.jobStatus, err - } + err = worker.loadResources(ctx) + if err != nil { + return worker.jobStatus, err } status := worker.jobStatus diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 51b4c1d468c..3ba89f5335d 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -56,8 +56,8 @@ func (r *JobProgressRecorder) Record(ctx context.Context, result JobResourceResu } r.results = append(r.results, result) - logger := logging.FromContext(ctx) if result.Error != nil { + logger := logging.FromContext(ctx) logger.Error("job resource operation failed", "err", result.Error, "path", result.Path, "resource", result.Resource, "group", result.Group, "action", result.Action, "name", result.Name) r.errors = append(r.errors, result.Error.Error()) } diff --git a/pkg/registry/apis/provisioning/jobs/sync/legacy.go b/pkg/registry/apis/provisioning/jobs/sync/legacy.go new file mode 100644 index 00000000000..12ad0e59586 --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/legacy.go @@ -0,0 +1,66 @@ +package sync + +import ( + "context" + "fmt" + "time" + + "google.golang.org/grpc/metadata" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana-app-sdk/logging" + dashboard "github.com/grafana/grafana/pkg/apis/dashboard" + folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +func (r *SyncWorker) wipeUnifiedAndSetMigratedFlag(ctx context.Context, ns string) error { + kinds := []schema.GroupResource{{ + Group: folders.GROUP, + Resource: folders.RESOURCE, + }, { + Group: dashboard.GROUP, + Resource: dashboard.DASHBOARD_RESOURCE, + }} + + for _, gr := range kinds { + status, _ := r.storageStatus.Status(ctx, gr) + if status.ReadUnified { + return fmt.Errorf("unexpected state - already using unified storage for: %s", gr) + } + if status.Migrating > 0 { + if time.Since(time.UnixMilli(status.Migrating)) < time.Second*30 { + return fmt.Errorf("another migration job is running for: %s", gr) + } + } + settings := resource.BatchSettings{ + RebuildCollection: true, // wipes everything in the collection + Collection: []*resource.ResourceKey{{ + Namespace: ns, + Group: gr.Group, + Resource: gr.Resource, + }}, + } + ctx = metadata.NewOutgoingContext(ctx, settings.ToMD()) + stream, err := r.batch.BatchProcess(ctx) + if err != nil { + return fmt.Errorf("error clearing unified %s / %w", gr, err) + } + stats, err := stream.CloseAndRecv() + if err != nil { + return fmt.Errorf("error clearing unified %s / %w", gr, err) + } + logger := logging.FromContext(ctx) + logger.Error("cleared unified stoage", "stats", stats) + + status.Migrated = time.Now().UnixMilli() // but not really... since the sync is starting + status.ReadUnified = true + status.WriteLegacy = false // keep legacy "clean" + _, err = r.storageStatus.Update(ctx, status) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker.go b/pkg/registry/apis/provisioning/jobs/sync/worker.go index 45839839106..63bf4725548 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/worker.go +++ b/pkg/registry/apis/provisioning/jobs/sync/worker.go @@ -28,25 +28,42 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/resource" ) // SyncWorker synchronizes the external repo with grafana database // this function updates the status for both the job and the referenced repository type SyncWorker struct { - client client.ProvisioningV0alpha1Interface + // Used to update the repository status with sync info + client client.ProvisioningV0alpha1Interface + + // Lists the values saved in grafana database + lister resources.ResourceLister + + // Parses fields saved in remore repository parsers *resources.ParserFactory - lister resources.ResourceLister + + // Check if the system is using unified storage + storageStatus dualwrite.Service + + // Direct access to unified storage... to wipe any existing values! + batch resource.BatchStoreClient } func NewSyncWorker( client client.ProvisioningV0alpha1Interface, parsers *resources.ParserFactory, lister resources.ResourceLister, + storageStatus dualwrite.Service, + batch resource.BatchStoreClient, ) *SyncWorker { return &SyncWorker{ - client: client, - parsers: parsers, - lister: lister, + client: client, + parsers: parsers, + lister: lister, + storageStatus: storageStatus, + batch: batch, } } @@ -80,6 +97,16 @@ func (r *SyncWorker) Process(ctx context.Context, return nil, fmt.Errorf("failed to create sync job: %w", err) } + // Check if we are onboarding from legacy storage + if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) { + if job.Spec.Sync.Incremental { + return nil, fmt.Errorf("incremental sync not suppored from legacy state") + } + if err = r.wipeUnifiedAndSetMigratedFlag(ctx, job.Namespace); err != nil { + return nil, err + } + } + // Execute the job syncError := syncJob.run(ctx, *job.Spec.Sync) jobStatus := progress.Complete(ctx, syncError) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 28003b9cb3b..07b8cafbe79 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -30,6 +30,7 @@ import ( clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned" informers "github.com/grafana/grafana/pkg/generated/informers/externalversions" listers "github.com/grafana/grafana/pkg/generated/listers/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" @@ -46,6 +47,7 @@ import ( "github.com/grafana/grafana/pkg/services/rendering" grafanasecrets "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/storage/unified/blob" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -78,6 +80,9 @@ type APIBuilder struct { tester *RepositoryTester resourceLister resources.ResourceLister repositoryLister listers.RepositoryLister + legacyMigrator legacy.LegacyMigrator + storageStatus dualwrite.Service + unified resource.ResourceClient secrets secrets.Service } @@ -90,11 +95,13 @@ func NewAPIBuilder( webhookSecretKey string, features featuremgmt.FeatureToggles, render rendering.Service, - index resource.RepositoryIndexClient, + unified resource.ResourceClient, blobstore blob.PublicBlobStore, clonedir string, // where repo clones are managed configProvider apiserver.RestConfigProvider, ghFactory github.ClientFactory, + legacyMigrator legacy.LegacyMigrator, + storageStatus dualwrite.Service, secrets secrets.Service, ) *APIBuilder { clientFactory := resources.NewFactory(configProvider) @@ -110,8 +117,11 @@ func NewAPIBuilder( }, render: render, clonedir: clonedir, - resourceLister: resources.NewResourceLister(index), + resourceLister: resources.NewResourceLister(unified), blobstore: blobstore, + legacyMigrator: legacyMigrator, + storageStatus: storageStatus, + unified: unified, secrets: secrets, } } @@ -128,6 +138,8 @@ func RegisterAPIService( client resource.ResourceClient, // implements resource.RepositoryClient configProvider apiserver.RestConfigProvider, ghFactory github.ClientFactory, + legacyMigrator legacy.LegacyMigrator, + storageStatus dualwrite.Service, // FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service! secretssvc grafanasecrets.Service, ) (*APIBuilder, error) { @@ -153,7 +165,9 @@ func RegisterAPIService( builder := NewAPIBuilder(folderResolver, urlProvider, cfg.SecretKey, features, render, client, store, filepath.Join(cfg.DataPath, "clone"), // where repositories are cloned (temporarialy for now) - configProvider, ghFactory, secrets.NewSingleTenant(secretssvc)) + configProvider, ghFactory, + legacyMigrator, storageStatus, + secrets.NewSingleTenant(secretssvc)) apiregistration.RegisterAPI(builder) return builder, nil } @@ -462,11 +476,19 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } b.repositoryLister = repoInformer.Lister() - b.jobs.Register(export.NewExportWorker(b.client, b.clonedir)) + b.jobs.Register(export.NewExportWorker( + b.client, + b.legacyMigrator, + b.storageStatus, + b.secrets, + b.clonedir, + )) b.jobs.Register(sync.NewSyncWorker( c.ProvisioningV0alpha1(), b.parsers, b.resourceLister, + b.storageStatus, + b.unified, )) renderer := pullrequest.NewRenderer(b.render, b.blobstore) diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go index d569fe39ffc..e15df09038e 100644 --- a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go +++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go @@ -22,11 +22,10 @@ import ( provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" ) -var ( - _ repository.Repository = (*GoGitRepo)(nil) -) +var _ repository.Repository = (*GoGitRepo)(nil) type GoGitCloneOptions struct { Root string // tempdir (when empty, memory??) @@ -36,8 +35,9 @@ type GoGitCloneOptions struct { } type GoGitRepo struct { - config *provisioning.Repository - opts GoGitCloneOptions + config *provisioning.Repository + opts GoGitCloneOptions + decryptedPassword string repo *git.Repository tree *git.Worktree @@ -50,6 +50,7 @@ func Clone( ctx context.Context, config *provisioning.Repository, opts GoGitCloneOptions, + secrets secrets.Service, progress io.Writer, // os.Stdout ) (*GoGitRepo, error) { gitcfg := config.Spec.GitHub @@ -59,7 +60,13 @@ func Clone( if opts.Root == "" { return nil, fmt.Errorf("missing root config") } - err := os.MkdirAll(opts.Root, 0700) + + decrypted, err := secrets.Decrypt(ctx, []byte(gitcfg.Token)) + if err != nil { + return nil, fmt.Errorf("error decrypting token %w", err) + } + + err = os.MkdirAll(opts.Root, 0700) if err != nil { return nil, err } @@ -68,8 +75,7 @@ func Clone( return nil, err } - url := fmt.Sprintf("/%s.git", gitcfg.URL) - + url := fmt.Sprintf("%s.git", gitcfg.URL) repo, err := git.PlainOpen(dir) if err != nil { if !errors.Is(err, git.ErrRepositoryNotExists) { @@ -78,8 +84,8 @@ func Clone( repo, err = git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ Auth: &githttp.BasicAuth{ - Username: "grafana", // this can be anything except an empty string for PAT - Password: gitcfg.Token, // TODO... will need to get from a service! + Username: "grafana", // this can be anything except an empty string for PAT + Password: string(decrypted), // TODO... will need to get from a service! }, URL: url, ReferenceName: plumbing.ReferenceName(gitcfg.Branch), @@ -117,11 +123,12 @@ func Clone( } return &GoGitRepo{ - config: config, - opts: opts, - tree: worktree, - repo: repo, - dir: dir, + config: config, + opts: opts, + tree: worktree, + decryptedPassword: string(decrypted), + repo: repo, + dir: dir, }, nil } @@ -174,7 +181,7 @@ func (g *GoGitRepo) Push(ctx context.Context, progress io.Writer) error { Progress: progress, Auth: &githttp.BasicAuth{ // reuse logic from clone? Username: "grafana", - Password: g.config.Spec.GitHub.Token, + Password: g.decryptedPassword, }, }) } @@ -204,7 +211,6 @@ func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.File entries = append(entries, entry) return err }) - if err != nil { return nil, err } diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go index 82ee7bbae50..e6d6fc05e66 100644 --- a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go +++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go @@ -45,6 +45,7 @@ func TestGoGitWrapper(t *testing.T) { // one commit (not 11) SingleCommitBeforePush: true, }, + nil, // TODO: add a mock os.Stdout) require.NoError(t, err) diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go index 3525b37b02d..11e48fe4b15 100644 --- a/pkg/registry/apis/provisioning/resources/tree.go +++ b/pkg/registry/apis/provisioning/resources/tree.go @@ -6,10 +6,11 @@ import ( "sort" "strings" - apiutils "github.com/grafana/grafana/pkg/apimachinery/utils" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/grafana/pkg/apimachinery/utils" folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // FolderTree contains the entire set of folders (at a given snapshot in time) of the Grafana instance. @@ -97,33 +98,21 @@ func NewEmptyFolderTree() *FolderTree { } } -func NewFolderTreeFromUnstructure(ctx context.Context, rawFolders []unstructured.Unstructured) *FolderTree { - tree := make(map[string]string, len(rawFolders)) - folders := make(map[string]Folder, len(rawFolders)) - - for _, rf := range rawFolders { - name := rf.GetName() - // TODO: Can I use MetaAccessor here? - parent := rf.GetAnnotations()[apiutils.AnnoKeyFolder] - tree[name] = parent - - id := Folder{ - Title: name, - ID: name, - // TODO: should not this be be the annotation itself? - Path: "", // We'll set this later in the DirPath function :) - } - if title, ok, _ := unstructured.NestedString(rf.Object, "spec", "title"); ok { - // If the title doesn't exist (it should), we'll just use the K8s name. - id.Title = title - } - folders[name] = id +func (t *FolderTree) AddUnstructured(item *unstructured.Unstructured, skipRepo string) error { + meta, err := utils.MetaAccessor(item) + if err != nil { + return err } - - return &FolderTree{ - tree: tree, - folders: folders, + if meta.GetRepositoryName() == skipRepo { + return nil // skip it... already in tree? } + folder := Folder{ + Title: meta.FindTitle(item.GetName()), + ID: item.GetName(), + } + t.tree[folder.ID] = meta.GetFolder() + t.folders[folder.ID] = folder + return nil } func NewFolderTreeFromResourceList(resources *provisioning.ResourceList) *FolderTree { diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go index 326d68558e3..b9a0ddc77c6 100644 --- a/pkg/registry/apis/provisioning/routes.go +++ b/pkg/registry/apis/provisioning/routes.go @@ -12,6 +12,7 @@ import ( authlib "github.com/grafana/authlib/types" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/util/errhttp" ) @@ -146,7 +147,8 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) { } settings := provisioning.RepositoryViewList{ - Items: make([]provisioning.RepositoryView, len(all)), + Items: make([]provisioning.RepositoryView, len(all)), + LegacyStorage: dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus), } for i, val := range all { settings.Items[i] = provisioning.RepositoryView{ diff --git a/pkg/server/wire.go b/pkg/server/wire.go index d7ea2b2b577..c3c94f816fb 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -36,6 +36,7 @@ import ( "github.com/grafana/grafana/pkg/middleware/csrf" "github.com/grafana/grafana/pkg/middleware/loggermw" apiregistry "github.com/grafana/grafana/pkg/registry/apis" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" appregistry "github.com/grafana/grafana/pkg/registry/apps" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -205,6 +206,7 @@ var wireBasicSet = wire.NewSet( uss.ProvideService, wire.Bind(new(usagestats.Service), new(*uss.UsageStats)), validator.ProvideService, + legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, pluginDashboards.ProvideFileStoreManager, wire.Bind(new(pluginDashboards.FileStore), new(*pluginDashboards.FileStoreManager)), diff --git a/pkg/storage/legacysql/dualwrite/managed_mode3.go b/pkg/storage/legacysql/dualwrite/managed_mode3.go index af1cee40613..48dd2d65fff 100644 --- a/pkg/storage/legacysql/dualwrite/managed_mode3.go +++ b/pkg/storage/legacysql/dualwrite/managed_mode3.go @@ -37,20 +37,20 @@ func (m *service) NewStorage(gr schema.GroupResource, } return &mangedMode3{ - service: m, - legacy: legacy, - unified: storage, - target: grafanarest.NewDualWriter(grafanarest.Mode3, legacy, storage, m.reg, gr.String()), - gr: gr, + service: m, + legacy: legacy, + unified: storage, + dualwrite: grafanarest.NewDualWriter(grafanarest.Mode3, legacy, storage, m.reg, gr.String()), + gr: gr, }, nil } type mangedMode3 struct { - service Service - legacy grafanarest.LegacyStorage - unified grafanarest.Storage - target grafanarest.Storage - gr schema.GroupResource + service Service + legacy grafanarest.LegacyStorage + unified grafanarest.Storage + dualwrite grafanarest.Storage + gr schema.GroupResource } func (d *mangedMode3) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { @@ -67,68 +67,78 @@ func (d *mangedMode3) List(ctx context.Context, options *metainternalversion.Lis return d.legacy.List(ctx, options) } -func (d *mangedMode3) isMigrating(ctx context.Context) error { +func (d *mangedMode3) getWriter(ctx context.Context) (grafanarest.Storage, error) { status, ok := d.service.Status(ctx, d.gr) if ok && status.Migrating > 0 { - return &apierrors.StatusError{ + return nil, &apierrors.StatusError{ ErrStatus: metav1.Status{ Code: http.StatusServiceUnavailable, Message: "the system is migrating", }, } } - return nil + if status.WriteLegacy { + if status.WriteUnified { + return d.dualwrite, nil + } + return d.legacy, nil // only write legacy (mode0) + } + return d.unified, nil // only write unified (mode4) } func (d *mangedMode3) Create(ctx context.Context, in runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { - if err := d.isMigrating(ctx); err != nil { + store, err := d.getWriter(ctx) + if err != nil { return nil, err } - return d.target.Create(ctx, in, createValidation, options) + return store.Create(ctx, in, createValidation, options) } func (d *mangedMode3) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { - if err := d.isMigrating(ctx); err != nil { + store, err := d.getWriter(ctx) + if err != nil { return nil, false, err } - return d.target.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options) + return store.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options) } func (d *mangedMode3) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - if err := d.isMigrating(ctx); err != nil { + store, err := d.getWriter(ctx) + if err != nil { return nil, false, err } - return d.target.Delete(ctx, name, deleteValidation, options) + return store.Delete(ctx, name, deleteValidation, options) } // DeleteCollection overrides the behavior of the generic DualWriter and deletes from both LegacyStorage and Storage. func (d *mangedMode3) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) { - if err := d.isMigrating(ctx); err != nil { + store, err := d.getWriter(ctx) + if err != nil { return nil, err } - return d.target.DeleteCollection(ctx, deleteValidation, options, listOptions) + return store.DeleteCollection(ctx, deleteValidation, options, listOptions) } func (d *mangedMode3) Destroy() { - d.target.Destroy() + d.dualwrite.Destroy() } func (d *mangedMode3) GetSingularName() string { - return d.target.GetSingularName() + return d.unified.GetSingularName() } func (d *mangedMode3) NamespaceScoped() bool { - return d.target.NamespaceScoped() + return d.unified.NamespaceScoped() } func (d *mangedMode3) New() runtime.Object { - return d.target.New() + return d.unified.New() } func (d *mangedMode3) NewList() runtime.Object { - return d.target.NewList() + return d.unified.NewList() } func (d *mangedMode3) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return d.target.ConvertToTable(ctx, object, tableOptions) + return d.unified.ConvertToTable(ctx, object, tableOptions) } diff --git a/pkg/storage/legacysql/dualwrite/service.go b/pkg/storage/legacysql/dualwrite/service.go index 1f231414bf5..563a14aa6b6 100644 --- a/pkg/storage/legacysql/dualwrite/service.go +++ b/pkg/storage/legacysql/dualwrite/service.go @@ -20,11 +20,10 @@ func ProvideService(features featuremgmt.FeatureToggles, reg prometheus.Register } return &service{ - db: newFileDB(path), - reg: reg, - enabled: features.IsEnabledGlobally(featuremgmt.FlagManagedDualWriter), - // TODO: when we can "export" from legacy, this can enabled along with provisioning - // || features.IsEnabledGlobally(featuremgmt.FlagProvisioning), // required for git provisioning + db: newFileDB(path), + reg: reg, + enabled: features.IsEnabledGlobally(featuremgmt.FlagManagedDualWriter) || + features.IsEnabledGlobally(featuremgmt.FlagProvisioning), // required for git provisioning } } diff --git a/pkg/storage/legacysql/dualwrite/storage_sql_mig.go b/pkg/storage/legacysql/dualwrite/storage_sql_mig.go index 3bb4650ff78..ce7a6f3f876 100644 --- a/pkg/storage/legacysql/dualwrite/storage_sql_mig.go +++ b/pkg/storage/legacysql/dualwrite/storage_sql_mig.go @@ -2,18 +2,23 @@ package dualwrite import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +// Not yet used... but you get the idea func AddUnifiedStatusMigrations(mg *migrator.Migrator) { resourceStorageStatus := migrator.Table{ Name: "resource_storage_status", Columns: []*migrator.Column{ {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "migrated", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp when we can start trusting unified storage - {Name: "migrating", Type: migrator.DB_BigInt, Nullable: false}, // Actively running a migration (start timestamp) + {Name: "write_legacy", Type: migrator.DB_Bool, Nullable: false, Default: "TRUE"}, + {Name: "write_unified", Type: migrator.DB_Bool, Nullable: false, Default: "TRUE"}, + {Name: "read_unified", Type: migrator.DB_Bool, Nullable: false}, + {Name: "migrating", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp Actively running a migration (start timestamp) + {Name: "migrated", Type: migrator.DB_BigInt, Nullable: false}, // Timestamp job finished + {Name: "runtime", Type: migrator.DB_Bool, Nullable: false, Default: "TRUE"}, {Name: "update_key", Type: migrator.DB_BigInt, Nullable: false}, // optimistic lock key -- required for update }, Indices: []*migrator.Index{ - {Cols: []string{"group", "resource", "namespace"}, Type: migrator.UniqueIndex}, + {Cols: []string{"group", "resource"}, Type: migrator.UniqueIndex}, }, } mg.AddMigration("create resource_storage_status table", migrator.NewAddTableMigration(resourceStorageStatus)) diff --git a/pkg/storage/legacysql/dualwrite/types.go b/pkg/storage/legacysql/dualwrite/types.go index 5dc4401db6b..3000ba91947 100644 --- a/pkg/storage/legacysql/dualwrite/types.go +++ b/pkg/storage/legacysql/dualwrite/types.go @@ -22,7 +22,7 @@ type StorageStatus struct { Migrated int64 `json:"migrated" xorm:"migrated"` // Timestamp when a migration *started* this should be cleared when finished - // While migrating all write commands will be unavaliable + // While migrating all write commands will be unavailable Migrating int64 `json:"migrating" xorm:"migrating"` // When false, the behavior will not change at runtime diff --git a/pkg/storage/legacysql/dualwrite/utils.go b/pkg/storage/legacysql/dualwrite/utils.go new file mode 100644 index 00000000000..27832aecc0d --- /dev/null +++ b/pkg/storage/legacysql/dualwrite/utils.go @@ -0,0 +1,18 @@ +package dualwrite + +import ( + "golang.org/x/net/context" + "k8s.io/apimachinery/pkg/runtime/schema" + + dashboard "github.com/grafana/grafana/pkg/apis/dashboard" + folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" +) + +func IsReadingLegacyDashboardsAndFolders(ctx context.Context, svc Service) bool { + f := svc.ReadFromUnified(ctx, folders.FolderResourceInfo.GroupResource()) + d := svc.ReadFromUnified(ctx, schema.GroupResource{ + Group: dashboard.GROUP, + Resource: dashboard.DASHBOARD_RESOURCE, + }) + return !(f && d) +} diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 68f0273d1bf..e42bcfce665 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -3032,6 +3032,10 @@ "resource": { "type": "string" }, + "total": { + "type": "integer", + "format": "int64" + }, "update": { "type": "integer", "format": "int64" @@ -3486,6 +3490,10 @@ "kind": { "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" + }, + "legacyStorage": { + "description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true", + "type": "boolean" } } }, diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx index 0808dc5c090..1a47060bab5 100644 --- a/public/app/features/provisioning/ConfigForm.tsx +++ b/public/app/features/provisioning/ConfigForm.tsx @@ -233,7 +233,7 @@ export function ConfigForm({ data }: ConfigFormProps) { /> - +
      diff --git a/public/app/features/provisioning/ExportToRepository.tsx b/public/app/features/provisioning/ExportToRepository.tsx index 81a549c1f2b..562d3594eb8 100644 --- a/public/app/features/provisioning/ExportToRepository.tsx +++ b/public/app/features/provisioning/ExportToRepository.tsx @@ -1,7 +1,6 @@ -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { Box, Button, Field, FieldSet, Input, Stack, Switch, Text } from '@grafana/ui'; -import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import ProgressBar from './ProgressBar'; import { Repository, useCreateRepositoryExportMutation, useListJobQuery, ExportJobOptions } from './api'; @@ -14,7 +13,7 @@ export function ExportToRepository({ repo }: Props) { const [exportRepo, exportQuery] = useCreateRepositoryExportMutation(); const exportName = exportQuery.data?.metadata?.name; - const { register, control, formState, handleSubmit } = useForm({ + const { register, formState, handleSubmit } = useForm({ defaultValues: { history: true, prefix: '', @@ -37,14 +36,6 @@ export function ExportToRepository({ repo }: Props) {
      - - } - /> - - {isGit && ( diff --git a/public/app/features/provisioning/RecentJobs.tsx b/public/app/features/provisioning/RecentJobs.tsx index ceed8ebe607..15266445f75 100644 --- a/public/app/features/provisioning/RecentJobs.tsx +++ b/public/app/features/provisioning/RecentJobs.tsx @@ -193,7 +193,7 @@ export function RecentJobs({ repo }: Props) { key={items?.length} data={items.slice(0, 20)} columns={jobColumns} - getRowId={(item) => item.metadata?.resourceVersion || ''} + getRowId={(item) => `${item.metadata?.name}`} renderExpandedRow={(row) => } /> )} diff --git a/public/app/features/provisioning/RepositoryListPage.tsx b/public/app/features/provisioning/RepositoryListPage.tsx index aa00bea056c..14c9f716a1e 100644 --- a/public/app/features/provisioning/RepositoryListPage.tsx +++ b/public/app/features/provisioning/RepositoryListPage.tsx @@ -11,6 +11,7 @@ import { Stack, TextLink, Text, + Alert, } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -18,17 +19,23 @@ import { DeleteRepositoryButton } from './DeleteRepositoryButton'; import { SetupWarnings } from './SetupWarnings'; import { StatusBadge } from './StatusBadge'; import { SyncRepository } from './SyncRepository'; -import { Repository, ResourceCount } from './api'; +import { Repository, ResourceCount, useGetFrontendSettingsQuery } from './api'; import { NEW_URL, PROVISIONING_URL } from './constants'; import { useRepositoryList } from './hooks'; export default function RepositoryListPage() { const [items, isLoading] = useRepositoryList({ watch: true }); + const settings = useGetFrontendSettingsQuery(); return ( + {settings.data?.legacyStorage && ( + + Require running the onboarding wizard to convert from legacy to unified + + )} diff --git a/public/app/features/provisioning/SetupWarnings.tsx b/public/app/features/provisioning/SetupWarnings.tsx index 86db36e1a48..18989d2b92a 100644 --- a/public/app/features/provisioning/SetupWarnings.tsx +++ b/public/app/features/provisioning/SetupWarnings.tsx @@ -25,12 +25,6 @@ kubernetesFoldersServiceV2 = true # If you want easy kubectl setup development mode grafanaAPIServerEnsureKubectlAccess = true -[unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 5 - -[unified_storage.folders.folder.grafana.app] -dualWriterMode = 5 - # For Github webhook support, you will need something like: [server] root_url = https://supreme-exact-beetle.ngrok-free.app`; diff --git a/public/app/features/provisioning/api/endpoints.gen.ts b/public/app/features/provisioning/api/endpoints.gen.ts index 9a16ac019ab..db71cc177eb 100644 --- a/public/app/features/provisioning/api/endpoints.gen.ts +++ b/public/app/features/provisioning/api/endpoints.gen.ts @@ -687,6 +687,7 @@ export type JobResourceSummary = { /** No action required (useful for sync) */ noop?: number; resource?: string; + total?: number; update?: number; write?: number; }; @@ -1067,6 +1068,8 @@ export type RepositoryViewList = { items: RepositoryView[]; /** 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 */ kind?: string; + /** The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true */ + legacyStorage?: boolean; }; export type ResourceStats = { /** 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 */ From 9ad66538711163b7cc48c58e13b44fe6b328be15 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 13 Feb 2025 07:20:17 -0700 Subject: [PATCH 44/78] Dashboard Schema V2: Improve diffing (#100022) * improve diffing * define dash spec props a-z * Fix * sort deep initialSaveModel * update tests * Fix test, description, and query ds issues * Fix seralizer test * response transformers * skip panelMerge tests --- .betterer.results | 6 ++- .../dashboard/v2alpha0/dashboard.schema.cue | 40 +++++++++--------- .../schema/dashboard/v2alpha0/types.gen.ts | 41 ++++++++++--------- public/app/core/utils/object.test.ts | 1 + public/app/core/utils/object.ts | 7 ++-- .../dashboard-scene/scene/DashboardScene.tsx | 3 +- .../DashboardSceneSerializer.test.ts | 20 +++++++++ .../transformSaveModelSchemaV2ToScene.test.ts | 9 ++-- .../transformSaveModelSchemaV2ToScene.ts | 2 +- .../transformSceneToSaveModelSchemaV2.ts | 11 ++--- .../api/ResponseTransformers.test.ts | 12 +++--- .../dashboard/api/ResponseTransformers.ts | 4 +- .../dashboard/state/DashboardModel.ts | 2 + .../dashboard/utils/panelMerge.test.ts | 3 +- 14 files changed, 97 insertions(+), 64 deletions(-) diff --git a/.betterer.results b/.betterer.results index 8db179df663..334e3e5dd2a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1324,8 +1324,10 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], "public/app/core/utils/richHistory.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] 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 21012f3461a..f9763bb219b 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -6,10 +6,7 @@ import ( DashboardV2Spec: { // Title of dashboard. - title: string - - // Description of dashboard. - description?: string + annotations: [...AnnotationQueryKind] // Configuration of dashboard cursor sync behavior. // "Off" for no shared crosshair or tooltip (default). @@ -17,6 +14,19 @@ DashboardV2Spec: { // "Tooltip" for shared crosshair AND shared tooltip. cursorSync: DashboardCursorSync + // Description of dashboard. + description?: string + + // Whether a dashboard is editable or not. + editable?: bool | *true + + elements: [ElementReference.name]: Element + + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind + + // Links with references to other dashboards or external websites. + links: [...DashboardLink] + // When set to true, the dashboard will redraw panels at an interval matching the pixel width. // This will keep data "moving left" regardless of the query refresh rate. This setting helps // avoid dashboards presenting stale live data. @@ -25,30 +35,20 @@ DashboardV2Spec: { // When set to true, the dashboard will load all panels in the dashboard when it's loaded. preload: bool - // Whether a dashboard is editable or not. - editable?: bool | *true - - // Links with references to other dashboards or external websites. - links: [...DashboardLink] + // Plugins only. The version of the dashboard installed together with the plugin. + // This is used to determine if the dashboard should be updated when the plugin is updated. + revision?: uint16 // Tags associated with dashboard. tags: [...string] timeSettings: TimeSettingsSpec + // Title of dashboard. + title: string + // Configured template variables. variables: [...VariableKind] - - elements: [ElementReference.name]: Element - - annotations: [...AnnotationQueryKind] - - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind - - - // Plugins only. The version of the dashboard installed together with the plugin. - // This is used to determine if the dashboard should be updated when the plugin is updated. - revision?: uint16 } // Supported dashboard elements diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index e85e71996fd..1ad02256fe8 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -5,49 +5,50 @@ import * as common from '@grafana/schema'; export interface DashboardV2Spec { // Title of dashboard. - title: string; - // Description of dashboard. - description?: string; + annotations: AnnotationQueryKind[]; // Configuration of dashboard cursor sync behavior. // "Off" for no shared crosshair or tooltip (default). // "Crosshair" for shared crosshair. // "Tooltip" for shared crosshair AND shared tooltip. cursorSync: DashboardCursorSync; + // Description of dashboard. + description?: string; + // Whether a dashboard is editable or not. + editable?: boolean; + elements: Record; + layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind; + // Links with references to other dashboards or external websites. + links: DashboardLink[]; // When set to true, the dashboard will redraw panels at an interval matching the pixel width. // This will keep data "moving left" regardless of the query refresh rate. This setting helps // avoid dashboards presenting stale live data. liveNow?: boolean; // When set to true, the dashboard will load all panels in the dashboard when it's loaded. preload: boolean; - // Whether a dashboard is editable or not. - editable?: boolean; - // Links with references to other dashboards or external websites. - links: DashboardLink[]; - // Tags associated with dashboard. - tags: string[]; - timeSettings: TimeSettingsSpec; - // Configured template variables. - variables: VariableKind[]; - elements: Record; - annotations: AnnotationQueryKind[]; - layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind; // Plugins only. The version of the dashboard installed together with the plugin. // This is used to determine if the dashboard should be updated when the plugin is updated. revision?: number; + // Tags associated with dashboard. + tags: string[]; + timeSettings: TimeSettingsSpec; + // Title of dashboard. + title: string; + // Configured template variables. + variables: VariableKind[]; } export const defaultDashboardV2Spec = (): DashboardV2Spec => ({ - title: "", + annotations: [], cursorSync: "Off", - preload: false, editable: true, + elements: {}, + layout: defaultGridLayoutKind(), links: [], + preload: false, tags: [], timeSettings: defaultTimeSettingsSpec(), + title: "", variables: [], - elements: {}, - annotations: [], - layout: defaultGridLayoutKind(), }); // Supported dashboard elements diff --git a/public/app/core/utils/object.test.ts b/public/app/core/utils/object.test.ts index 8e27feccc6b..c84f7d71d9e 100644 --- a/public/app/core/utils/object.test.ts +++ b/public/app/core/utils/object.test.ts @@ -7,6 +7,7 @@ describe('objects', () => { deeper: 10, foo: null, arr: [null, 1, 'hello'], + value: -Infinity, }, bar: undefined, simple: 'A', diff --git a/public/app/core/utils/object.ts b/public/app/core/utils/object.ts index a5cccf419c2..a51d593b635 100644 --- a/public/app/core/utils/object.ts +++ b/public/app/core/utils/object.ts @@ -1,16 +1,17 @@ import { isArray, isPlainObject } from 'lodash'; /** @returns a deep clone of the object, but with any null value removed */ -export function sortedDeepCloneWithoutNulls(value: T): T { +export function sortedDeepCloneWithoutNulls(value: T): T { if (isArray(value)) { return value.map(sortedDeepCloneWithoutNulls) as unknown as T; } if (isPlainObject(value)) { - return Object.keys(value) + return Object.keys(value as { [key: string]: any }) .sort() .reduce((acc: any, key) => { const v = (value as any)[key]; - if (v != null) { + // Remove null values and also -Infinity which is not a valid JSON value + if (v != null && v !== -Infinity) { acc[key] = sortedDeepCloneWithoutNulls(v); } return acc; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 0a088325aec..1be803a66ea 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -30,6 +30,7 @@ import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getNavModel } from 'app/core/selectors/navModel'; import store from 'app/core/store'; +import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; @@ -674,7 +675,7 @@ export class DashboardScene extends SceneObjectBase { saveModel?: Dashboard | DashboardV2Spec, meta?: DashboardMeta | DashboardWithAccessInfo['metadata'] ): void { - this._serializer.initialSaveModel = saveModel; + this._serializer.initialSaveModel = sortedDeepCloneWithoutNulls(saveModel); this._serializer.metadata = meta; } diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index 747c69eaf07..74d58c10719 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -36,6 +36,26 @@ jest.mock('@grafana/runtime', () => ({ getInstanceSettings: jest.fn(), }; }, + config: { + ...jest.requireActual('@grafana/runtime').config, + bootData: { + settings: { + defaultDatasource: '-- Grafana --', + datasources: { + '-- Grafana --': { + name: 'Grafana', + meta: { id: 'grafana' }, + type: 'datasource', + }, + prometheus: { + name: 'prometheus', + meta: { id: 'prometheus' }, + type: 'datasource', + }, + }, + }, + }, + }, })); describe('DashboardSceneSerializer', () => { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 90033cc9856..108a1e1d159 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -293,7 +293,7 @@ describe('transformSaveModelSchemaV2ToScene', () => { expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME); }); - it('should set panel ds as undefined if it is not mixed DS', () => { + it('should set ds if it is not mixed DS', () => { const dashboard = cloneDeep(defaultDashboard); getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({ kind: 'PanelQuery', @@ -317,10 +317,13 @@ describe('transformSaveModelSchemaV2ToScene', () => { const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels(); expect(vizPanels.length).toBe(3); - expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined(); + expect(getQueryRunnerFor(vizPanels[0])?.state.queries[0].datasource).toEqual({ + type: 'prometheus', + uid: 'datasource1', + }); }); - it('should set panel ds as mixed if one ds is undefined', () => { + it('should set panel ds as mixed if no panels have ds defined', () => { const dashboard = cloneDeep(defaultDashboard); getPanelElement(dashboard.spec, 'panel-1')?.spec.data.spec.queries.push({ diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index a8f97ad4287..40b5b01fbf9 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -234,7 +234,7 @@ function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { } }); - return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined; + return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : datasource; } function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 979fba6483c..3672567fdb9 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -11,6 +11,7 @@ import { VizPanel, } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; +import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; import { DashboardV2Spec, @@ -73,7 +74,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps const dashboardSchemaV2: DeepPartial = { //dashboard settings title: sceneDash.title, - description: sceneDash.description ?? '', + description: sceneDash.description, cursorSync: getCursorSync(sceneDash), liveNow: getLiveNow(sceneDash), preload: sceneDash.preload, @@ -116,7 +117,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps try { // validateDashboardSchemaV2 will throw an error if the dashboard is not valid if (validateDashboardSchemaV2(dashboardSchemaV2)) { - return dashboardSchemaV2; + return sortedDeepCloneWithoutNulls(dashboardSchemaV2); } // should never reach this point, validation should throw an error throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2); @@ -241,7 +242,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] { const queries: PanelQueryKind[] = []; const queryRunner = getQueryRunnerFor(vizPanel); const vizPanelQueries = queryRunner?.state.queries; - const datasource = queryRunner?.state.datasource; + const datasource = queryRunner?.state.datasource ?? getDefaultDataSourceRef(); if (vizPanelQueries) { vizPanelQueries.forEach((query) => { @@ -250,7 +251,7 @@ function getVizPanelQueries(vizPanel: VizPanel): PanelQueryKind[] { spec: omit(query, 'datasource', 'refId', 'hide'), }; const querySpec: PanelQuerySpec = { - datasource: datasource ?? getDefaultDataSourceRef(), + datasource: query.datasource ?? datasource, query: dataQuery, refId: query.refId, hidden: Boolean(query.hide), @@ -446,7 +447,7 @@ function validateDashboardSchemaV2(dash: unknown): dash is DashboardV2Spec { if ('title' in dash && typeof dash.title !== 'string') { throw new Error('Title is not a string'); } - if ('description' in dash && typeof dash.description !== 'string') { + if ('description' in dash && dash.description !== undefined && typeof dash.description !== 'string') { throw new Error('Description is not a string'); } if ('cursorSync' in dash && typeof dash.cursorSync !== 'string') { diff --git a/public/app/features/dashboard/api/ResponseTransformers.test.ts b/public/app/features/dashboard/api/ResponseTransformers.test.ts index c598db36084..4a7a29cf711 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.test.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.test.ts @@ -487,7 +487,7 @@ describe('ResponseTransformers', () => { expect(layout.spec.items[0].spec).toEqual({ element: { kind: 'ElementReference', - name: '1', + name: 'panel-1', }, x: 0, y: 0, @@ -495,7 +495,7 @@ describe('ResponseTransformers', () => { height: 8, repeat: { value: 'var1', direction: 'h', mode: 'variable', maxPerRow: undefined }, }); - expect(spec.elements['1']).toEqual({ + expect(spec.elements['panel-1']).toEqual({ kind: 'Panel', spec: { title: 'Panel Title', @@ -550,14 +550,14 @@ describe('ResponseTransformers', () => { expect(layout.spec.items[1].spec).toEqual({ element: { kind: 'ElementReference', - name: '2', + name: 'panel-2', }, x: 0, y: 8, width: 12, height: 8, }); - expect(spec.elements['2']).toEqual({ + expect(spec.elements['panel-2']).toEqual({ kind: 'LibraryPanel', spec: { libraryPanel: { @@ -580,7 +580,7 @@ describe('ResponseTransformers', () => { expect(panelInRow).toEqual({ element: { kind: 'ElementReference', - name: '4', + name: 'panel-4', }, x: 0, y: 0, @@ -598,7 +598,7 @@ describe('ResponseTransformers', () => { expect(panelInCollapsedRow).toEqual({ element: { kind: 'ElementReference', - name: '5', + name: 'panel-5', }, x: 0, y: 0, diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 69edee427e4..3d810f3eedf 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -383,7 +383,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] { }, }; - return [panelKind, p.id!.toString()]; + return [panelKind, `panel-${p.id}`]; } else { // PanelKind @@ -433,7 +433,7 @@ function buildElement(p: Panel): [PanelKind | LibraryPanelKind, string] { }, }; - return [panelKind, p.id!.toString()]; + return [panelKind, `panel-${p.id}`]; } } diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index f640cc8cbb6..c54e1743f96 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -286,6 +286,8 @@ export class DashboardModel implements TimeModel { * * @internal and experimental */ + // TODO: remove this as it's not being used anymore + // Also remove public/app/features/dashboard/utils/panelMerge.ts updatePanels(panels: IPanelModel[]): PanelMergeInfo { const info = mergePanels(this.panels, panels ?? []); if (info.changed) { diff --git a/public/app/features/dashboard/utils/panelMerge.test.ts b/public/app/features/dashboard/utils/panelMerge.test.ts index 4bc201aa8ad..51536b2d426 100644 --- a/public/app/features/dashboard/utils/panelMerge.test.ts +++ b/public/app/features/dashboard/utils/panelMerge.test.ts @@ -4,7 +4,8 @@ import { FieldColorModeId, ThresholdsMode } from '@grafana/schema/src'; import { DashboardModel } from '../state/DashboardModel'; import { createDashboardModelFixture, createPanelSaveModel } from '../state/__fixtures__/dashboardFixtures'; -describe('Merge dashboard panels', () => { +// skipping these tests because panelMerge is not used +describe.skip('Merge dashboard panels', () => { describe('simple changes', () => { let dashboard: DashboardModel; let rawPanels: PanelModel[]; From 71f97f380de49c21527babdf01e8de975adb4ae1 Mon Sep 17 00:00:00 2001 From: Victor Cinaglia Date: Thu, 13 Feb 2025 09:35:35 -0500 Subject: [PATCH 45/78] Docs: Fix URLs to auth providers from Team Sync page (#100563) * iam/docs: fix links to providers in team sync page * iam/docs: make auth proxy link look more like other links --- .../introduction/grafana-enterprise.md | 2 +- .../configure-security/configure-team-sync.md | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index 28a647254ef..e9317e632a4 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -33,7 +33,7 @@ Grafana Enterprise includes integrations with more ways to authenticate your use Supported auth providers: -- [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}}) +- [Auth Proxy](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only) - [Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only) - [Generic OAuth integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization) - [GitHub OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization) diff --git a/docs/sources/setup-grafana/configure-security/configure-team-sync.md b/docs/sources/setup-grafana/configure-security/configure-team-sync.md index f5d50cff311..89665525574 100644 --- a/docs/sources/setup-grafana/configure-security/configure-team-sync.md +++ b/docs/sources/setup-grafana/configure-security/configure-team-sync.md @@ -27,16 +27,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea ## Supported providers -- [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}}) -- [Azure AD](https://grafana.com/docs/grafana//configure-authentication/azuread#group-sync-enterprise-only) -- [Azure AD](https://grafana.com/docs/grafana//configure-security/configure-authentication/azuread#group-sync-enterprise-only) -- [Generic OAuth integration](https://grafana.com/docs/grafana//configure-security/configure-authentication/generic-oauth#configure-group-synchronization) -- [GitHub OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/github#configure-group-synchronization) -- [GitLab OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/gitlab#configure-group-synchronization) -- [Google OAuth](https://grafana.com/docs/grafana//configure-security/configure-authentication/google#configure-group-synchronization) -- [LDAP](https://grafana.com/docs/grafana//configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) -- [Okta](https://grafana.com/docs/grafana//configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) -- [SAML](https://grafana.com/docs/grafana//configure-security/configure-authentication/saml#configure-group-synchronization) +- [Auth Proxy](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/auth-proxy/#team-sync-enterprise-only) +- [Azure AD](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only) +- [Generic OAuth integration](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization) +- [GitHub OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization) +- [GitLab OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization) +- [Google OAuth](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization) +- [LDAP](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization) +- [Okta](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only) +- [SAML](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization) ## Synchronize a Grafana team with an external group From 9dd75aee328b77f681a7b0715fca1a2f86a7744f Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Thu, 13 Feb 2025 09:45:16 -0500 Subject: [PATCH 46/78] Alerting: Refactor State Transition (part 2 of n) (#99985) * split create to create and patch and move to state patch will be refactored further * move setNextState to state transition * move tests * split tests for patch function --- pkg/services/ngalert/state/cache.go | 75 ---- pkg/services/ngalert/state/cache_test.go | 311 ---------------- pkg/services/ngalert/state/manager.go | 128 +------ pkg/services/ngalert/state/state.go | 174 +++++++++ ...ache_bench_test.go => state_bench_test.go} | 8 +- pkg/services/ngalert/state/state_test.go | 331 ++++++++++++++++++ 6 files changed, 526 insertions(+), 501 deletions(-) rename pkg/services/ngalert/state/{cache_bench_test.go => state_bench_test.go} (85%) diff --git a/pkg/services/ngalert/state/cache.go b/pkg/services/ngalert/state/cache.go index 1abeff1c2a9..a7302fc5518 100644 --- a/pkg/services/ngalert/state/cache.go +++ b/pkg/services/ngalert/state/cache.go @@ -143,81 +143,6 @@ func expandAnnotationsAndLabels(ctx context.Context, log log.Logger, alertRule * return lbs, annotations } -func (c *cache) create(ctx context.Context, log log.Logger, alertRule *ngModels.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) *State { - lbs, annotations := expandAnnotationsAndLabels(ctx, log, alertRule, result, extraLabels, externalURL) - - cacheID := lbs.Fingerprint() - // For new states, we set StartsAt & EndsAt to EvaluatedAt as this is the - // expected value for a Normal state during state transition. - newState := State{ - OrgID: alertRule.OrgID, - AlertRuleUID: alertRule.UID, - CacheID: cacheID, - State: eval.Normal, - StateReason: "", - ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint - LatestResult: nil, - Error: nil, - Image: nil, - Annotations: annotations, - Labels: lbs, - Values: nil, - StartsAt: result.EvaluatedAt, - EndsAt: result.EvaluatedAt, - ResolvedAt: nil, - LastSentAt: nil, - LastEvaluationString: "", - LastEvaluationTime: result.EvaluatedAt, - EvaluationDuration: result.EvaluationDuration, - } - - existingState := c.get(alertRule.OrgID, alertRule.UID, cacheID) - if existingState == nil { - return &newState - } - // if there is existing state, copy over the current values that may be needed to determine the final state. - // TODO remove some unnecessary assignments below because they are overridden in setNextState - newState.State = existingState.State - newState.StateReason = existingState.StateReason - newState.Image = existingState.Image - newState.LatestResult = existingState.LatestResult - newState.Error = existingState.Error - newState.Values = existingState.Values - newState.LastEvaluationString = existingState.LastEvaluationString - newState.StartsAt = existingState.StartsAt - newState.EndsAt = existingState.EndsAt - newState.ResolvedAt = existingState.ResolvedAt - newState.LastSentAt = existingState.LastSentAt - // Annotations can change over time, however we also want to maintain - // certain annotations across evaluations - for key := range ngModels.InternalAnnotationNameSet { // Changing in - value, ok := existingState.Annotations[key] - if !ok { - continue - } - // If the annotation is not present then it should be copied from - // the current state to the new state - if _, ok = newState.Annotations[key]; !ok { - newState.Annotations[key] = value - } - } - - // if the current state is "data source error" then it may have additional labels that may not exist in the new state. - // See https://github.com/grafana/grafana/blob/c7fdf8ce706c2c9d438f5e6eabd6e580bac4946b/pkg/services/ngalert/state/state.go#L161-L163 - // copy known labels over to the new instance, it can help reduce flapping - // TODO fix this? - if existingState.State == eval.Error && result.State == eval.Error { - setIfExist := func(lbl string) { - if v, ok := existingState.Labels[lbl]; ok { - newState.Labels[lbl] = v - } - } - setIfExist("datasource_uid") - setIfExist("ref_id") - } - return &newState -} - // expand returns the expanded templates of all annotations or labels for the template data. // If a template cannot be expanded due to an error in the template the original template is // maintained and an error is added to the multierror. All errors in the multierror are diff --git a/pkg/services/ngalert/state/cache_test.go b/pkg/services/ngalert/state/cache_test.go index 9fe50fdb120..6defc25086d 100644 --- a/pkg/services/ngalert/state/cache_test.go +++ b/pkg/services/ngalert/state/cache_test.go @@ -3,15 +3,11 @@ package state import ( "context" "errors" - "fmt" "math/rand" - "net/url" "testing" "time" - "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -118,313 +114,6 @@ func Test_expand(t *testing.T) { }) } -func Test_create(t *testing.T) { - url := &url.URL{ - Scheme: "http", - Host: "localhost:3000", - Path: "/test", - } - l := log.New("test") - c := newCache() - - gen := models.RuleGen - generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef - - t.Run("should combine all labels", func(t *testing.T) { - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(5, "extra-") - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - state := c.create(context.Background(), l, rule, result, extraLabels, url) - for key, expected := range extraLabels { - require.Equal(t, expected, state.Labels[key]) - } - assert.Len(t, state.Labels, len(extraLabels)+len(rule.Labels)+len(result.Instance)) - for key, expected := range extraLabels { - assert.Equal(t, expected, state.Labels[key]) - } - for key, expected := range rule.Labels { - assert.Equal(t, expected, state.Labels[key]) - } - for key, expected := range result.Instance { - assert.Equal(t, expected, state.Labels[key]) - } - }) - t.Run("extra labels should take precedence over rule and result labels", func(t *testing.T) { - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(2, "extra-") - - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - for key := range extraLabels { - rule.Labels[key] = "rule-" + util.GenerateShortUID() - result.Instance[key] = "result-" + util.GenerateShortUID() - } - - state := c.create(context.Background(), l, rule, result, extraLabels, url) - for key, expected := range extraLabels { - require.Equal(t, expected, state.Labels[key]) - } - }) - t.Run("rule labels should take precedence over result labels", func(t *testing.T) { - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(2, "extra-") - - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - for key := range rule.Labels { - result.Instance[key] = "result-" + util.GenerateShortUID() - } - state := c.create(context.Background(), l, rule, result, extraLabels, url) - for key, expected := range rule.Labels { - require.Equal(t, expected, state.Labels[key]) - } - }) - t.Run("rule labels should be able to be expanded with result and extra labels", func(t *testing.T) { - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(2, "extra-") - - labelTemplates := make(data.Labels) - for key := range extraLabels { - labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) - } - for key := range result.Instance { - labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) - } - rule.Labels = labelTemplates - - state := c.create(context.Background(), l, rule, result, extraLabels, url) - for key, expected := range extraLabels { - assert.Equal(t, expected, state.Labels["rule-"+key]) - } - for key, expected := range result.Instance { - assert.Equal(t, expected, state.Labels["rule-"+key]) - } - }) - t.Run("rule annotations should be able to be expanded with result and extra labels", func(t *testing.T) { - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(2, "extra-") - - annotationTemplates := make(data.Labels) - for key := range extraLabels { - annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) - } - for key := range result.Instance { - annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) - } - rule.Annotations = annotationTemplates - - state := c.create(context.Background(), l, rule, result, extraLabels, url) - for key, expected := range extraLabels { - assert.Equal(t, expected, state.Annotations["rule-"+key]) - } - for key, expected := range result.Instance { - assert.Equal(t, expected, state.Annotations["rule-"+key]) - } - }) - t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) { - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - m := models.LabelsUserCannotSpecify - t.Cleanup(func() { - models.LabelsUserCannotSpecify = m - }) - - models.LabelsUserCannotSpecify = map[string]struct{}{ - "__label1__": {}, - "label2__": {}, - "__label3": {}, - "label4": {}, - } - result.Instance["__label1__"] = uuid.NewString() - result.Instance["label2__"] = uuid.NewString() - result.Instance["__label3"] = uuid.NewString() - result.Instance["label4"] = uuid.NewString() - - rule := generateRule() - - state := c.create(context.Background(), l, rule, result, nil, url) - - for key := range models.LabelsUserCannotSpecify { - assert.NotContains(t, state.Labels, key) - } - assert.Contains(t, state.Labels, "label1") - assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"]) - - assert.Contains(t, state.Labels, "label2") - assert.Equal(t, state.Labels["label2"], result.Instance["label2__"]) - - assert.Contains(t, state.Labels, "label3") - assert.Equal(t, state.Labels["label3"], result.Instance["__label3"]) - - assert.Contains(t, state.Labels, "label4_user") - assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"]) - - t.Run("should drop label if renamed collides with existing", func(t *testing.T) { - result.Instance["label1"] = uuid.NewString() - result.Instance["label1_user"] = uuid.NewString() - result.Instance["label4_user"] = uuid.NewString() - - state = c.create(context.Background(), l, rule, result, nil, url) - assert.NotContains(t, state.Labels, "__label1__") - assert.Contains(t, state.Labels, "label1") - assert.Equal(t, state.Labels["label1"], result.Instance["label1"]) - assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"]) - - assert.NotContains(t, state.Labels, "label4") - assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"]) - }) - }) - - t.Run("creates a state with preset fields if there is no current state", func(t *testing.T) { - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(2, "extra-") - - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - - expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url) - - state := c.create(context.Background(), l, rule, result, extraLabels, url) - - assert.Equal(t, rule.OrgID, state.OrgID) - assert.Equal(t, rule.UID, state.AlertRuleUID) - assert.Equal(t, state.Labels.Fingerprint(), state.CacheID) - assert.Equal(t, result.State, state.State) - assert.Equal(t, "", state.StateReason) - assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint) - assert.Nil(t, state.LatestResult) - assert.Nil(t, state.Error) - assert.Nil(t, state.Image) - assert.EqualValues(t, expectedAnn, state.Annotations) - assert.EqualValues(t, expectedLbl, state.Labels) - assert.Nil(t, state.Values) - assert.Equal(t, result.EvaluatedAt, state.StartsAt) - assert.Equal(t, result.EvaluatedAt, state.EndsAt) - assert.Nil(t, state.ResolvedAt) - assert.Nil(t, state.LastSentAt) - assert.Equal(t, "", state.LastEvaluationString) - assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime) - assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration) - }) - - t.Run("it populates some fields from the current state if it exists", func(t *testing.T) { - rule := generateRule() - - extraLabels := models.GenerateAlertLabels(2, "extra-") - - result := eval.Result{ - Instance: models.GenerateAlertLabels(5, "result-"), - } - - expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url) - - current := randomSate(rule.GetKey()) - current.CacheID = expectedLbl.Fingerprint() - - c.set(¤t) - - state := c.create(context.Background(), l, rule, result, extraLabels, url) - - assert.Equal(t, rule.OrgID, state.OrgID) - assert.Equal(t, rule.UID, state.AlertRuleUID) - assert.Equal(t, state.Labels.Fingerprint(), state.CacheID) - assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint) - assert.EqualValues(t, expectedAnn, state.Annotations) - assert.EqualValues(t, expectedLbl, state.Labels) - assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime) - assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration) - - assert.Equal(t, current.State, state.State) - assert.Equal(t, current.StateReason, state.StateReason) - assert.Equal(t, current.Image, state.Image) - assert.Equal(t, current.LatestResult, state.LatestResult) - assert.Equal(t, current.Error, state.Error) - assert.Equal(t, current.Values, state.Values) - assert.Equal(t, current.StartsAt, state.StartsAt) - assert.Equal(t, current.EndsAt, state.EndsAt) - assert.Equal(t, current.ResolvedAt, state.ResolvedAt) - assert.Equal(t, current.LastSentAt, state.LastSentAt) - assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString) - - t.Run("if result Error and current state is Error it should copy datasource_uid and ref_id labels", func(t *testing.T) { - current = randomSate(rule.GetKey()) - current.CacheID = expectedLbl.Fingerprint() - current.State = eval.Error - current.Labels["datasource_uid"] = util.GenerateShortUID() - current.Labels["ref_id"] = util.GenerateShortUID() - - c.set(¤t) - - result.State = eval.Error - state = c.create(context.Background(), l, rule, result, extraLabels, url) - - l := expectedLbl.Copy() - l["datasource_uid"] = current.Labels["datasource_uid"] - l["ref_id"] = current.Labels["ref_id"] - - assert.Equal(t, current.CacheID, state.CacheID) - assert.EqualValues(t, l, state.Labels) - - assert.Equal(t, rule.OrgID, state.OrgID) - assert.Equal(t, rule.UID, state.AlertRuleUID) - - assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint) - assert.EqualValues(t, expectedAnn, state.Annotations) - assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime) - assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration) - - assert.Equal(t, current.State, state.State) - assert.Equal(t, current.StateReason, state.StateReason) - assert.Equal(t, current.Image, state.Image) - assert.Equal(t, current.LatestResult, state.LatestResult) - assert.Equal(t, current.Error, state.Error) - assert.Equal(t, current.Values, state.Values) - assert.Equal(t, current.StartsAt, state.StartsAt) - assert.Equal(t, current.EndsAt, state.EndsAt) - assert.Equal(t, current.ResolvedAt, state.ResolvedAt) - assert.Equal(t, current.LastSentAt, state.LastSentAt) - assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString) - }) - t.Run("copies system-owned annotations from current state", func(t *testing.T) { - current = randomSate(rule.GetKey()) - current.CacheID = expectedLbl.Fingerprint() - current.State = eval.Error - for key := range models.InternalAnnotationNameSet { - current.Annotations[key] = util.GenerateShortUID() - } - c.set(¤t) - - result.State = eval.Error - state = c.create(context.Background(), l, rule, result, extraLabels, url) - ann := expectedAnn.Copy() - for key := range models.InternalAnnotationNameSet { - ann[key] = current.Annotations[key] - } - assert.EqualValues(t, expectedLbl, state.Labels) - assert.EqualValues(t, ann, state.Annotations) - }) - }) -} - func Test_mergeLabels(t *testing.T) { t.Run("merges two maps", func(t *testing.T) { a := models.GenerateAlertLabels(5, "set1-") diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index cc22a65f517..f66002bbb3b 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -444,9 +444,16 @@ func (st *Manager) setNextStateForRule(ctx context.Context, alertRule *ngModels. } transitions := make([]StateTransition, 0, len(results)) for _, result := range results { - currentState := st.cache.create(ctx, logger, alertRule, result, extraLabels, st.externalURL) - s := st.setNextState(alertRule, currentState, result, nil, logger, takeImageFn) - st.cache.set(currentState) // replace the existing state with the new one + newState := newState(ctx, logger, alertRule, result, extraLabels, st.externalURL) + if curState := st.cache.get(alertRule.OrgID, alertRule.UID, newState.CacheID); curState != nil { + patch(newState, curState, result) + } + start := st.clock.Now() + s := newState.transition(alertRule, result, nil, logger, takeImageFn) + if st.metrics != nil { + st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds()) + } + st.cache.set(newState) // replace the existing state with the new one transitions = append(transitions, s) } return transitions @@ -459,8 +466,12 @@ func (st *Manager) setNextStateForAll(alertRule *ngModels.AlertRule, result eval states: make(map[data.Fingerprint]*State, len(currentStates)), } for _, currentState := range currentStates { + start := st.clock.Now() newState := currentState.Copy() - t := st.setNextState(alertRule, newState, result, extraAnnotations, logger, takeImageFn) + t := newState.transition(alertRule, result, extraAnnotations, logger, takeImageFn) + if st.metrics != nil { + st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds()) + } updated.states[newState.CacheID] = newState transitions = append(transitions, t) } @@ -468,115 +479,6 @@ func (st *Manager) setNextStateForAll(alertRule *ngModels.AlertRule, result eval return transitions } -// Set the current state based on evaluation results -func (st *Manager) setNextState(alertRule *ngModels.AlertRule, currentState *State, result eval.Result, extraAnnotations data.Labels, logger log.Logger, takeImageFn takeImageFn) StateTransition { - start := st.clock.Now() - - currentState.LastEvaluationTime = result.EvaluatedAt - currentState.EvaluationDuration = result.EvaluationDuration - currentState.SetNextValues(result) - currentState.LatestResult = &Evaluation{ - EvaluationTime: result.EvaluatedAt, - EvaluationState: result.State, - Values: currentState.Values, - Condition: alertRule.Condition, - } - currentState.LastEvaluationString = result.EvaluationString - oldState := currentState.State - oldReason := currentState.StateReason - - // Add the instance to the log context to help correlate log lines for a state - logger = logger.New("instance", result.Instance) - - // if the current state is Error but the result is different, then we need o clean up the extra labels - // that were added after the state key was calculated - // https://github.com/grafana/grafana/blob/1df4d332c982dc5e394201bb2ef35b442727ce63/pkg/services/ngalert/state/state.go#L298-L311 - // Usually, it happens in the case of classic conditions when the evalResult does not have labels. - // - // This is temporary change to make sure that the labels are not persistent in the state after it was in Error state - // TODO yuri. Remove it when correct Error result with labels is provided - if currentState.State == eval.Error && result.State != eval.Error { - // This is possible because state was updated after the CacheID was calculated. - _, curOk := currentState.Labels["ref_id"] - _, resOk := result.Instance["ref_id"] - if curOk && !resOk { - delete(currentState.Labels, "ref_id") - } - _, curOk = currentState.Labels["datasource_uid"] - _, resOk = result.Instance["datasource_uid"] - if curOk && !resOk { - delete(currentState.Labels, "datasource_uid") - } - } - - switch result.State { - case eval.Normal: - logger.Debug("Setting next state", "handler", "resultNormal") - resultNormal(currentState, alertRule, result, logger, "") - case eval.Alerting: - logger.Debug("Setting next state", "handler", "resultAlerting") - resultAlerting(currentState, alertRule, result, logger, "") - case eval.Error: - logger.Debug("Setting next state", "handler", "resultError") - resultError(currentState, alertRule, result, logger) - case eval.NoData: - logger.Debug("Setting next state", "handler", "resultNoData") - resultNoData(currentState, alertRule, result, logger) - case eval.Pending: // we do not emit results with this state - logger.Debug("Ignoring set next state as result is pending") - } - - // Set reason iff: result and state are different, reason is not Alerting or Normal - currentState.StateReason = "" - - if currentState.State != result.State && - result.State != eval.Normal && - result.State != eval.Alerting { - currentState.StateReason = resultStateReason(result, alertRule) - } - - // Set Resolved property so the scheduler knows to send a postable alert - // to Alertmanager. - newlyResolved := false - if oldState == eval.Alerting && currentState.State == eval.Normal { - currentState.ResolvedAt = &result.EvaluatedAt - newlyResolved = true - } else if currentState.State != eval.Normal && currentState.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending. - currentState.ResolvedAt = nil - } - - if reason := shouldTakeImage(currentState.State, oldState, currentState.Image, newlyResolved); reason != "" { - image := takeImageFn(reason) - if image != nil { - currentState.Image = image - } - } - - for key, val := range extraAnnotations { - currentState.Annotations[key] = val - } - - nextState := StateTransition{ - State: currentState, - PreviousState: oldState, - PreviousStateReason: oldReason, - } - - if st.metrics != nil { - st.metrics.StateUpdateDuration.Observe(st.clock.Now().Sub(start).Seconds()) - } - - return nextState -} - -func resultStateReason(result eval.Result, rule *ngModels.AlertRule) string { - if rule.ExecErrState == ngModels.KeepLastErrState || rule.NoDataState == ngModels.KeepLast { - return ngModels.ConcatReasons(result.State.String(), ngModels.StateReasonKeepLast) - } - - return result.State.String() -} - func (st *Manager) GetAll(orgID int64) []*State { allStates := st.cache.getAll(orgID) return allStates diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index aee77903096..54694664cca 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "math" + "net/url" "strings" "time" @@ -76,6 +77,35 @@ type State struct { EvaluationDuration time.Duration } +func newState(ctx context.Context, log log.Logger, alertRule *models.AlertRule, result eval.Result, extraLabels data.Labels, externalURL *url.URL) *State { + lbs, annotations := expandAnnotationsAndLabels(ctx, log, alertRule, result, extraLabels, externalURL) + + cacheID := lbs.Fingerprint() + // For new states, we set StartsAt & EndsAt to EvaluatedAt as this is the + // expected value for a Normal state during state transition. + return &State{ + OrgID: alertRule.OrgID, + AlertRuleUID: alertRule.UID, + CacheID: cacheID, + State: eval.Normal, + StateReason: "", + ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint + LatestResult: nil, + Error: nil, + Image: nil, + Annotations: annotations, + Labels: lbs, + Values: nil, + StartsAt: result.EvaluatedAt, + EndsAt: result.EvaluatedAt, + ResolvedAt: nil, + LastSentAt: nil, + LastEvaluationString: "", + LastEvaluationTime: result.EvaluatedAt, + EvaluationDuration: result.EvaluationDuration, + } +} + // Copy creates a shallow copy of the State except for labels and annotations. func (a *State) Copy() *State { // Deep copy annotations and labels @@ -664,3 +694,147 @@ func GetRuleExtraLabels(l log.Logger, rule *models.AlertRule, folderTitle string } return extraLabels } + +func patch(newState, existingState *State, result eval.Result) { + // if there is existing state, copy over the current values that may be needed to determine the final state. + // TODO remove some unnecessary assignments below because they are overridden in setNextState + newState.State = existingState.State + newState.StateReason = existingState.StateReason + newState.Image = existingState.Image + newState.LatestResult = existingState.LatestResult + newState.Error = existingState.Error + newState.Values = existingState.Values + newState.LastEvaluationString = existingState.LastEvaluationString + newState.StartsAt = existingState.StartsAt + newState.EndsAt = existingState.EndsAt + newState.ResolvedAt = existingState.ResolvedAt + newState.LastSentAt = existingState.LastSentAt + // Annotations can change over time, however we also want to maintain + // certain annotations across evaluations + for key := range models.InternalAnnotationNameSet { // Changing in + value, ok := existingState.Annotations[key] + if !ok { + continue + } + // If the annotation is not present then it should be copied from + // the current state to the new state + if _, ok = newState.Annotations[key]; !ok { + newState.Annotations[key] = value + } + } + + // if the current state is "data source error" then it may have additional labels that may not exist in the new state. + // See https://github.com/grafana/grafana/blob/c7fdf8ce706c2c9d438f5e6eabd6e580bac4946b/pkg/services/ngalert/state/state.go#L161-L163 + // copy known labels over to the new instance, it can help reduce flapping + // TODO fix this? + if existingState.State == eval.Error && result.State == eval.Error { + setIfExist := func(lbl string) { + if v, ok := existingState.Labels[lbl]; ok { + newState.Labels[lbl] = v + } + } + setIfExist("datasource_uid") + setIfExist("ref_id") + } +} + +func (a *State) transition(alertRule *models.AlertRule, result eval.Result, extraAnnotations data.Labels, logger log.Logger, takeImageFn takeImageFn) StateTransition { + a.LastEvaluationTime = result.EvaluatedAt + a.EvaluationDuration = result.EvaluationDuration + a.SetNextValues(result) + a.LatestResult = &Evaluation{ + EvaluationTime: result.EvaluatedAt, + EvaluationState: result.State, + Values: a.Values, + Condition: alertRule.Condition, + } + a.LastEvaluationString = result.EvaluationString + oldState := a.State + oldReason := a.StateReason + + // Add the instance to the log context to help correlate log lines for a state + logger = logger.New("instance", result.Instance) + + // if the current state is Error but the result is different, then we need o clean up the extra labels + // that were added after the state key was calculated + // https://github.com/grafana/grafana/blob/1df4d332c982dc5e394201bb2ef35b442727ce63/pkg/services/ngalert/state/state.go#L298-L311 + // Usually, it happens in the case of classic conditions when the evalResult does not have labels. + // + // This is temporary change to make sure that the labels are not persistent in the state after it was in Error state + // TODO yuri. Remove it when correct Error result with labels is provided + if a.State == eval.Error && result.State != eval.Error { + // This is possible because state was updated after the CacheID was calculated. + _, curOk := a.Labels["ref_id"] + _, resOk := result.Instance["ref_id"] + if curOk && !resOk { + delete(a.Labels, "ref_id") + } + _, curOk = a.Labels["datasource_uid"] + _, resOk = result.Instance["datasource_uid"] + if curOk && !resOk { + delete(a.Labels, "datasource_uid") + } + } + + switch result.State { + case eval.Normal: + logger.Debug("Setting next state", "handler", "resultNormal") + resultNormal(a, alertRule, result, logger, "") + case eval.Alerting: + logger.Debug("Setting next state", "handler", "resultAlerting") + resultAlerting(a, alertRule, result, logger, "") + case eval.Error: + logger.Debug("Setting next state", "handler", "resultError") + resultError(a, alertRule, result, logger) + case eval.NoData: + logger.Debug("Setting next state", "handler", "resultNoData") + resultNoData(a, alertRule, result, logger) + case eval.Pending: // we do not emit results with this state + logger.Debug("Ignoring set next state as result is pending") + } + + // Set reason iff: result and state are different, reason is not Alerting or Normal + a.StateReason = "" + + if a.State != result.State && + result.State != eval.Normal && + result.State != eval.Alerting { + a.StateReason = resultStateReason(result, alertRule) + } + + // Set Resolved property so the scheduler knows to send a postable alert + // to Alertmanager. + newlyResolved := false + if oldState == eval.Alerting && a.State == eval.Normal { + a.ResolvedAt = &result.EvaluatedAt + newlyResolved = true + } else if a.State != eval.Normal && a.State != eval.Pending { // Retain the last resolved time for Normal->Normal and Normal->Pending. + a.ResolvedAt = nil + } + + if reason := shouldTakeImage(a.State, oldState, a.Image, newlyResolved); reason != "" { + image := takeImageFn(reason) + if image != nil { + a.Image = image + } + } + + for key, val := range extraAnnotations { + a.Annotations[key] = val + } + + nextState := StateTransition{ + State: a, + PreviousState: oldState, + PreviousStateReason: oldReason, + } + return nextState +} + +func resultStateReason(result eval.Result, rule *models.AlertRule) string { + if rule.ExecErrState == models.KeepLastErrState || rule.NoDataState == models.KeepLast { + return models.ConcatReasons(result.State.String(), models.StateReasonKeepLast) + } + + return result.State.String() +} diff --git a/pkg/services/ngalert/state/cache_bench_test.go b/pkg/services/ngalert/state/state_bench_test.go similarity index 85% rename from pkg/services/ngalert/state/cache_bench_test.go rename to pkg/services/ngalert/state/state_bench_test.go index 357eabdd9e3..8f1ec2550c8 100644 --- a/pkg/services/ngalert/state/cache_bench_test.go +++ b/pkg/services/ngalert/state/state_bench_test.go @@ -14,7 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" ) -func BenchmarkGetOrCreateTest(b *testing.B) { +func BenchmarkCreateAndPatch(b *testing.B) { cache := newCache() rule := models.RuleGen.With(func(rule *models.AlertRule) { for i := 0; i < 2; i++ { @@ -43,7 +43,11 @@ func BenchmarkGetOrCreateTest(b *testing.B) { // values := make([]int64, count) b.RunParallel(func(pb *testing.PB) { for pb.Next() { - _ = cache.create(ctx, log, rule, result, nil, u) + s := newState(ctx, log, rule, result, nil, u) + current := cache.get(rule.OrgID, rule.UID, s.CacheID) + if current == nil { + patch(s, current, result) + } } }) } diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index 2515fe33190..7216784a65c 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -3,14 +3,17 @@ package state import ( "context" "errors" + "fmt" "math" "math/rand" + "net/url" "testing" "time" "github.com/benbjohnson/clock" "github.com/golang/mock/gomock" "github.com/google/uuid" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -801,3 +804,331 @@ func TestGetRuleExtraLabels(t *testing.T) { }) } } + +func TestNewState(t *testing.T) { + url := &url.URL{ + Scheme: "http", + Host: "localhost:3000", + Path: "/test", + } + l := log.New("test") + + gen := ngmodels.RuleGen + generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef + + t.Run("should combine all labels", func(t *testing.T) { + rule := generateRule() + + extraLabels := ngmodels.GenerateAlertLabels(5, "extra-") + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + state := newState(context.Background(), l, rule, result, extraLabels, url) + for key, expected := range extraLabels { + require.Equal(t, expected, state.Labels[key]) + } + assert.Len(t, state.Labels, len(extraLabels)+len(rule.Labels)+len(result.Instance)) + for key, expected := range extraLabels { + assert.Equal(t, expected, state.Labels[key]) + } + for key, expected := range rule.Labels { + assert.Equal(t, expected, state.Labels[key]) + } + for key, expected := range result.Instance { + assert.Equal(t, expected, state.Labels[key]) + } + }) + t.Run("extra labels should take precedence over rule and result labels", func(t *testing.T) { + rule := generateRule() + + extraLabels := ngmodels.GenerateAlertLabels(2, "extra-") + + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + for key := range extraLabels { + rule.Labels[key] = "rule-" + util.GenerateShortUID() + result.Instance[key] = "result-" + util.GenerateShortUID() + } + + state := newState(context.Background(), l, rule, result, extraLabels, url) + for key, expected := range extraLabels { + require.Equal(t, expected, state.Labels[key]) + } + }) + t.Run("rule labels should take precedence over result labels", func(t *testing.T) { + rule := generateRule() + + extraLabels := ngmodels.GenerateAlertLabels(2, "extra-") + + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + for key := range rule.Labels { + result.Instance[key] = "result-" + util.GenerateShortUID() + } + state := newState(context.Background(), l, rule, result, extraLabels, url) + for key, expected := range rule.Labels { + require.Equal(t, expected, state.Labels[key]) + } + }) + t.Run("rule labels should be able to be expanded with result and extra labels", func(t *testing.T) { + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + rule := generateRule() + + extraLabels := ngmodels.GenerateAlertLabels(2, "extra-") + + labelTemplates := make(data.Labels) + for key := range extraLabels { + labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) + } + for key := range result.Instance { + labelTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) + } + rule.Labels = labelTemplates + + state := newState(context.Background(), l, rule, result, extraLabels, url) + for key, expected := range extraLabels { + assert.Equal(t, expected, state.Labels["rule-"+key]) + } + for key, expected := range result.Instance { + assert.Equal(t, expected, state.Labels["rule-"+key]) + } + }) + t.Run("rule annotations should be able to be expanded with result and extra labels", func(t *testing.T) { + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + + rule := generateRule() + + extraLabels := ngmodels.GenerateAlertLabels(2, "extra-") + + annotationTemplates := make(data.Labels) + for key := range extraLabels { + annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) + } + for key := range result.Instance { + annotationTemplates["rule-"+key] = fmt.Sprintf("{{ with (index .Labels \"%s\") }}{{.}}{{end}}", key) + } + rule.Annotations = annotationTemplates + + state := newState(context.Background(), l, rule, result, extraLabels, url) + for key, expected := range extraLabels { + assert.Equal(t, expected, state.Annotations["rule-"+key]) + } + for key, expected := range result.Instance { + assert.Equal(t, expected, state.Annotations["rule-"+key]) + } + }) + t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) { + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + m := ngmodels.LabelsUserCannotSpecify + t.Cleanup(func() { + ngmodels.LabelsUserCannotSpecify = m + }) + + ngmodels.LabelsUserCannotSpecify = map[string]struct{}{ + "__label1__": {}, + "label2__": {}, + "__label3": {}, + "label4": {}, + } + result.Instance["__label1__"] = uuid.NewString() + result.Instance["label2__"] = uuid.NewString() + result.Instance["__label3"] = uuid.NewString() + result.Instance["label4"] = uuid.NewString() + + rule := generateRule() + + state := newState(context.Background(), l, rule, result, nil, url) + + for key := range ngmodels.LabelsUserCannotSpecify { + assert.NotContains(t, state.Labels, key) + } + assert.Contains(t, state.Labels, "label1") + assert.Equal(t, state.Labels["label1"], result.Instance["__label1__"]) + + assert.Contains(t, state.Labels, "label2") + assert.Equal(t, state.Labels["label2"], result.Instance["label2__"]) + + assert.Contains(t, state.Labels, "label3") + assert.Equal(t, state.Labels["label3"], result.Instance["__label3"]) + + assert.Contains(t, state.Labels, "label4_user") + assert.Equal(t, state.Labels["label4_user"], result.Instance["label4"]) + + t.Run("should drop label if renamed collides with existing", func(t *testing.T) { + result.Instance["label1"] = uuid.NewString() + result.Instance["label1_user"] = uuid.NewString() + result.Instance["label4_user"] = uuid.NewString() + + state = newState(context.Background(), l, rule, result, nil, url) + assert.NotContains(t, state.Labels, "__label1__") + assert.Contains(t, state.Labels, "label1") + assert.Equal(t, state.Labels["label1"], result.Instance["label1"]) + assert.Equal(t, state.Labels["label1_user"], result.Instance["label1_user"]) + + assert.NotContains(t, state.Labels, "label4") + assert.Equal(t, state.Labels["label4_user"], result.Instance["label4_user"]) + }) + }) + + t.Run("creates a state with preset fields if there is no current state", func(t *testing.T) { + rule := generateRule() + + extraLabels := ngmodels.GenerateAlertLabels(2, "extra-") + + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + + expectedLbl, expectedAnn := expandAnnotationsAndLabels(context.Background(), l, rule, result, extraLabels, url) + + state := newState(context.Background(), l, rule, result, extraLabels, url) + + assert.Equal(t, rule.OrgID, state.OrgID) + assert.Equal(t, rule.UID, state.AlertRuleUID) + assert.Equal(t, state.Labels.Fingerprint(), state.CacheID) + assert.Equal(t, result.State, state.State) + assert.Equal(t, "", state.StateReason) + assert.Equal(t, result.Instance.Fingerprint(), state.ResultFingerprint) + assert.Nil(t, state.LatestResult) + assert.Nil(t, state.Error) + assert.Nil(t, state.Image) + assert.EqualValues(t, expectedAnn, state.Annotations) + assert.EqualValues(t, expectedLbl, state.Labels) + assert.Nil(t, state.Values) + assert.Equal(t, result.EvaluatedAt, state.StartsAt) + assert.Equal(t, result.EvaluatedAt, state.EndsAt) + assert.Nil(t, state.ResolvedAt) + assert.Nil(t, state.LastSentAt) + assert.Equal(t, "", state.LastEvaluationString) + assert.Equal(t, result.EvaluatedAt, state.LastEvaluationTime) + assert.Equal(t, result.EvaluationDuration, state.EvaluationDuration) + }) +} + +func TestPatch(t *testing.T) { + key := ngmodels.GenerateRuleKey(1) + t.Run("it populates some fields from the current state if it exists", func(t *testing.T) { + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + + state := randomSate(key) + orig := state.Copy() + current := randomSate(key) + + patch(&state, ¤t, result) + + // Fields that should not change + assert.Equal(t, orig.OrgID, state.OrgID) + assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID) + assert.Equal(t, orig.CacheID, state.CacheID) + assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint) + assert.EqualValues(t, orig.Annotations, state.Annotations) + assert.EqualValues(t, orig.Labels, state.Labels) + assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime) + assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration) + + assert.Equal(t, current.State, state.State) + assert.Equal(t, current.StateReason, state.StateReason) + assert.Equal(t, current.Image, state.Image) + assert.Equal(t, current.LatestResult, state.LatestResult) + assert.Equal(t, current.Error, state.Error) + assert.Equal(t, current.Values, state.Values) + assert.Equal(t, current.StartsAt, state.StartsAt) + assert.Equal(t, current.EndsAt, state.EndsAt) + assert.Equal(t, current.ResolvedAt, state.ResolvedAt) + assert.Equal(t, current.LastSentAt, state.LastSentAt) + assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString) + }) + + t.Run("copies system-owned annotations from current state", func(t *testing.T) { + state := randomSate(key) + orig := state.Copy() + expectedAnnotations := data.Labels(state.Annotations).Copy() + current := randomSate(key) + + for key := range ngmodels.InternalAnnotationNameSet { + val := util.GenerateShortUID() + current.Annotations[key] = val + expectedAnnotations[key] = val + } + + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + } + + patch(&state, ¤t, result) + + assert.EqualValues(t, expectedAnnotations, state.Annotations) + assert.Equal(t, current.State, state.State) + assert.Equal(t, current.StateReason, state.StateReason) + assert.Equal(t, current.Image, state.Image) + assert.Equal(t, current.LatestResult, state.LatestResult) + assert.Equal(t, current.Error, state.Error) + assert.Equal(t, current.Values, state.Values) + assert.Equal(t, current.StartsAt, state.StartsAt) + assert.Equal(t, current.EndsAt, state.EndsAt) + assert.Equal(t, current.ResolvedAt, state.ResolvedAt) + assert.Equal(t, current.LastSentAt, state.LastSentAt) + assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString) + + // Fields that should not change + assert.Equal(t, orig.OrgID, state.OrgID) + assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID) + assert.Equal(t, orig.CacheID, state.CacheID) + assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint) + assert.EqualValues(t, orig.Labels, state.Labels) + assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime) + assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration) + }) + + t.Run("if result Error and current state is Error it should copy datasource_uid and ref_id labels", func(t *testing.T) { + state := randomSate(key) + orig := state.Copy() + current := randomSate(key) + current.State = eval.Error + current.Labels["datasource_uid"] = util.GenerateShortUID() + current.Labels["ref_id"] = util.GenerateShortUID() + + result := eval.Result{ + Instance: ngmodels.GenerateAlertLabels(5, "result-"), + State: eval.Error, + } + + expectedLabels := orig.Labels.Copy() + expectedLabels["datasource_uid"] = current.Labels["datasource_uid"] + expectedLabels["ref_id"] = current.Labels["ref_id"] + + patch(&state, ¤t, result) + + assert.Equal(t, expectedLabels, state.Labels) + assert.Equal(t, current.State, state.State) + assert.Equal(t, current.StateReason, state.StateReason) + assert.Equal(t, current.Image, state.Image) + assert.Equal(t, current.LatestResult, state.LatestResult) + assert.Equal(t, current.Error, state.Error) + assert.Equal(t, current.Values, state.Values) + assert.Equal(t, current.StartsAt, state.StartsAt) + assert.Equal(t, current.EndsAt, state.EndsAt) + assert.Equal(t, current.ResolvedAt, state.ResolvedAt) + assert.Equal(t, current.LastSentAt, state.LastSentAt) + assert.Equal(t, current.LastEvaluationString, state.LastEvaluationString) + + // Fields that should not change + assert.Equal(t, orig.OrgID, state.OrgID) + assert.Equal(t, orig.AlertRuleUID, state.AlertRuleUID) + assert.Equal(t, orig.CacheID, state.CacheID) + assert.Equal(t, orig.ResultFingerprint, state.ResultFingerprint) + assert.Equal(t, orig.LastEvaluationTime, state.LastEvaluationTime) + assert.Equal(t, orig.EvaluationDuration, state.EvaluationDuration) + assert.EqualValues(t, orig.Annotations, state.Annotations) + }) +} From 7edcde63650b3070b2b050c738cb1fb509731780 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 13 Feb 2025 15:46:22 +0100 Subject: [PATCH 47/78] Dashboards: Bring back scripted dashboards (#100575) * Dashboards: Bring back scripted dashboards * Fix scripted dashboard examples * Fix dashboard-solo page not respecnig scripted dashboards --- .../pages/DashboardScenePage.tsx | 2 + .../pages/DashboardScenePageStateManager.ts | 9 ++++- .../dashboard-scene/solo/SoloPanelPage.tsx | 7 ++-- .../containers/DashboardPageProxy.tsx | 1 + public/app/routes/routes.tsx | 6 ++- public/dashboards/scripted.js | 28 +++++--------- public/dashboards/scripted_async.js | 3 +- public/dashboards/scripted_templated.js | 38 ++++++++++++------- 8 files changed, 52 insertions(+), 42 deletions(-) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 6ac66d3d312..19a4c412b42 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -37,6 +37,8 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { stateManager.loadSnapshot(slug!); } else { stateManager.loadDashboard({ + type, + slug, uid: uid ?? '', route: route.routeName as DashboardRoutes, urlFolderUid: queryParams.folderUid, diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 161ee48f10c..b9f37706bad 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -63,6 +63,7 @@ export interface LoadDashboardOptions { uid: string; route: DashboardRoutes; type?: string; + slug?: string; urlFolderUid?: string; params?: { version: number; @@ -266,6 +267,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } public async fetchDashboard({ + type, + slug, uid, route, urlFolderUid, @@ -323,7 +326,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } : undefined; - rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid, queryParams); + rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParams); if (route === DashboardRoutes.Embedded) { rsp.meta.isEmbedded = true; @@ -477,6 +480,8 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan } public async fetchDashboard({ + type, + slug, uid, route, urlFolderUid, @@ -529,7 +534,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan ...params.variables, } : undefined; - rsp = await this.dashboardLoader.loadDashboard('db', '', uid, queryParams); + rsp = await this.dashboardLoader.loadDashboard(type || 'db', slug || '', uid, queryParams); if (route === DashboardRoutes.Embedded) { throw new Error('Method not implemented.'); // rsp.meta.isEmbedded = true; diff --git a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx index 265d32c9e72..01894fe9d9e 100644 --- a/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx +++ b/public/app/features/dashboard-scene/solo/SoloPanelPage.tsx @@ -26,12 +26,12 @@ export interface Props extends GrafanaRouteComponentProps { - stateManager.loadDashboard({ uid, route: DashboardRoutes.Embedded }); + stateManager.loadDashboard({ uid, type, slug, route: DashboardRoutes.Embedded }); return () => stateManager.clearState(); - }, [stateManager, queryParams, uid]); + }, [stateManager, queryParams, uid, type, slug]); if (!queryParams.panelId) { return ; @@ -64,7 +64,6 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard const [panel, error] = useSoloPanel(dashboard, panelId); const { controls } = dashboard.useState(); const refreshPicker = controls?.useState()?.refreshPicker; - const styles = useStyles2(getStyles); useEffect(() => { diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx index 52d91d5d956..1570fbc6ee7 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx @@ -51,6 +51,7 @@ function DashboardPageProxy(props: DashboardPageProxyProps) { route: props.route.routeName as DashboardRoutes, uid: params.uid ?? '', type: params.type, + slug: params.slug, }); }, [params.uid, props.route.routeName]); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 839eb62ad85..e518b69eb60 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -96,8 +96,10 @@ export function getAppRoutes(): RouteDescriptor[] { path: '/dashboard-solo/:type/:slug', routeName: DashboardRoutes.Normal, chromeless: true, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + component: SafeDynamicImport(() => + config.featureToggles.dashboardSceneSolo + ? import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard-scene/solo/SoloPanelPage') + : import(/* webpackChunkName: "SoloPanelPageOld" */ '../features/dashboard/containers/SoloPanelPage') ), }, { diff --git a/public/dashboards/scripted.js b/public/dashboards/scripted.js index daa4c4efb70..03de55bff44 100644 --- a/public/dashboards/scripted.js +++ b/public/dashboards/scripted.js @@ -13,16 +13,11 @@ 'use strict'; -// accessible variables in this scope -let window, document, $, jQuery, moment, kbn; +// accessible variables in this scope: window, document, $, jQuery, moment, kbn; // Setup some variables let dashboard; -// All url parameters are available via the ARGS object -// eslint-disable-next-line no-redeclare -let ARGS; - // Initialize a skeleton with nothing but a rows array and service object dashboard = { rows: [], @@ -56,6 +51,7 @@ for (let i = 0; i < rows; i++) { height: '300px', panels: [ { + id: 1, title: 'Events', type: 'graph', span: 12, @@ -63,23 +59,17 @@ for (let i = 0; i < rows; i++) { linewidth: 2, targets: [ { - target: "randomWalk('" + seriesName + "')", + scenarioId: 'random_walk', + refId: 'A', + seriesCount: 1, + alias: seriesName, }, { - target: "randomWalk('random walk2')", + scenarioId: 'random_walk', + refId: 'B', + seriesCount: 1, }, ], - seriesOverrides: [ - { - alias: '/random/', - yaxis: 2, - fill: 0, - linewidth: 5, - }, - ], - tooltip: { - shared: true, - }, }, ], }); diff --git a/public/dashboards/scripted_async.js b/public/dashboards/scripted_async.js index 98fb144d243..d13a80eb034 100644 --- a/public/dashboards/scripted_async.js +++ b/public/dashboards/scripted_async.js @@ -17,7 +17,7 @@ 'use strict'; // accessible variables in this scope -let window, document, ARGS, $, jQuery, moment, kbn; +// let window, document, ARGS, $, jQuery, moment, kbn; return function (callback) { // Setup some variables @@ -60,6 +60,7 @@ return function (callback) { height: '300px', panels: [ { + id: 1, title: 'Async dashboard test', type: 'text', span: 12, diff --git a/public/dashboards/scripted_templated.js b/public/dashboards/scripted_templated.js index df8647ff86e..0b09fa50986 100644 --- a/public/dashboards/scripted_templated.js +++ b/public/dashboards/scripted_templated.js @@ -14,14 +14,14 @@ 'use strict'; // accessible variables in this scope -let window, document, $, jQuery, moment, kbn; +// let window, document, $, jQuery, moment, kbn; // Setup some variables let dashboard; // All url parameters are available via the ARGS object // eslint-disable-next-line no-redeclare -let ARGS; +// let ARGS; // Initialize a skeleton with nothing but a rows array and service object dashboard = { @@ -44,19 +44,22 @@ dashboard.templating = { list: [ { name: 'test', - query: 'apps.backend.*', - refresh: 1, - type: 'query', - datasource: null, hide: 2, + includeAll: false, + multi: false, + query: 'a,b,c\n', + skipUrlSync: false, + type: 'custom', }, { - name: 'test2', - query: '*', - refresh: 1, - type: 'query', - datasource: null, - hide: 2, + name: 'seriesName', + label: 'Series name', + hide: 0, + includeAll: false, + multi: false, + query: 'series1,series2,series3\n', + skipUrlSync: false, + type: 'custom', }, ], }; @@ -78,6 +81,7 @@ for (let i = 0; i < rows; i++) { height: '300px', panels: [ { + id: 1, title: 'Events', type: 'graph', span: 12, @@ -85,10 +89,16 @@ for (let i = 0; i < rows; i++) { linewidth: 2, targets: [ { - target: "randomWalk('" + seriesName + "')", + scenarioId: 'random_walk', + refId: 'A', + seriesCount: 1, + alias: seriesName, }, { - target: "randomWalk('[[test2]]')", + scenarioId: 'random_walk', + refId: 'B', + seriesCount: 1, + alias: '${seriesName}', }, ], }, From 527fc3bb21f969d829c932e79e5226f7e1a47d43 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Thu, 13 Feb 2025 15:56:16 +0100 Subject: [PATCH 48/78] Dashboards: Remove `schemaVersion < min_version` validation (#100555) --- pkg/apis/dashboard/migration/migrate.go | 4 ---- pkg/apis/dashboard/migration/migrate_test.go | 9 --------- .../dashboard/migration/schemaversion/errors.go | 15 --------------- .../migration/schemaversion/migrations.go | 5 +---- pkg/apis/dashboard/v1alpha1/conversion.go | 9 +-------- pkg/apis/dashboard/v2alpha1/conversion.go | 9 +-------- 6 files changed, 3 insertions(+), 48 deletions(-) diff --git a/pkg/apis/dashboard/migration/migrate.go b/pkg/apis/dashboard/migration/migrate.go index 36a4679f191..2a419596767 100644 --- a/pkg/apis/dashboard/migration/migrate.go +++ b/pkg/apis/dashboard/migration/migrate.go @@ -9,10 +9,6 @@ func Migrate(dash map[string]interface{}, targetVersion int) error { inputVersion := schemaversion.GetSchemaVersion(dash) dash["schemaVersion"] = inputVersion - if inputVersion < schemaversion.MINIUM_VERSION { - return schemaversion.NewMinimumVersionError(inputVersion) - } - for nextVersion := inputVersion + 1; nextVersion <= targetVersion; nextVersion++ { if migration, ok := schemaversion.Migrations[nextVersion]; ok { if err := migration(dash); err != nil { diff --git a/pkg/apis/dashboard/migration/migrate_test.go b/pkg/apis/dashboard/migration/migrate_test.go index a24baa993ee..e037c37f00f 100644 --- a/pkg/apis/dashboard/migration/migrate_test.go +++ b/pkg/apis/dashboard/migration/migrate_test.go @@ -22,15 +22,6 @@ func TestMigrate(t *testing.T) { files, err := os.ReadDir(INPUT_DIR) require.NoError(t, err) - t.Run("minimum version check", func(t *testing.T) { - err := migration.Migrate(map[string]interface{}{ - "schemaVersion": schemaversion.MINIUM_VERSION - 1, - }, schemaversion.MINIUM_VERSION) - - var minVersionErr = schemaversion.NewMinimumVersionError(schemaversion.MINIUM_VERSION - 1) - require.ErrorAs(t, err, &minVersionErr) - }) - for _, f := range files { if f.IsDir() { continue diff --git a/pkg/apis/dashboard/migration/schemaversion/errors.go b/pkg/apis/dashboard/migration/schemaversion/errors.go index 110a596a1ad..f5bbbe7d1fa 100644 --- a/pkg/apis/dashboard/migration/schemaversion/errors.go +++ b/pkg/apis/dashboard/migration/schemaversion/errors.go @@ -2,23 +2,8 @@ package schemaversion import "fmt" -var _ error = &MinimumVersionError{} var _ error = &MigrationError{} -// MinimumVersionError is an error that is returned when the schema version is below the minimum version. -func NewMinimumVersionError(inputVersion int) *MinimumVersionError { - return &MinimumVersionError{inputVersion: inputVersion} -} - -// MinimumVersionError is an error type for minimum version errors. -type MinimumVersionError struct { - inputVersion int -} - -func (e *MinimumVersionError) Error() string { - return fmt.Errorf("input schema version is below minimum version. input: %d minimum: %d", e.inputVersion, MINIUM_VERSION).Error() -} - // ErrMigrationFailed is an error that is returned when a migration fails. func NewMigrationError(msg string, currentVersion, targetVersion int) *MigrationError { return &MigrationError{ diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations.go b/pkg/apis/dashboard/migration/schemaversion/migrations.go index ef46439a591..3d83da73a3f 100644 --- a/pkg/apis/dashboard/migration/schemaversion/migrations.go +++ b/pkg/apis/dashboard/migration/schemaversion/migrations.go @@ -4,10 +4,7 @@ import "strconv" type SchemaVersionMigrationFunc func(map[string]interface{}) error -const ( - MINIUM_VERSION = 36 - LATEST_VERSION = 41 -) +const LATEST_VERSION = 41 var Migrations = map[int]SchemaVersionMigrationFunc{ 37: V37, diff --git a/pkg/apis/dashboard/v1alpha1/conversion.go b/pkg/apis/dashboard/v1alpha1/conversion.go index 358c482c4e3..c01b13db29c 100644 --- a/pkg/apis/dashboard/v1alpha1/conversion.go +++ b/pkg/apis/dashboard/v1alpha1/conversion.go @@ -1,8 +1,6 @@ package v1alpha1 import ( - "errors" - conversion "k8s.io/apimachinery/pkg/conversion" klog "k8s.io/klog/v2" @@ -15,12 +13,7 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct out.Unstructured = *in err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION) if err != nil { - minErr := &schemaversion.MinimumVersionError{} - if errors.As(err, &minErr) { - out.Unstructured.Object["__migrationError"] = err.Error() - } else { - return err - } + return err } t, ok := out.Unstructured.Object["title"].(string) diff --git a/pkg/apis/dashboard/v2alpha1/conversion.go b/pkg/apis/dashboard/v2alpha1/conversion.go index 280cac67b14..9a1818fc0c3 100644 --- a/pkg/apis/dashboard/v2alpha1/conversion.go +++ b/pkg/apis/dashboard/v2alpha1/conversion.go @@ -1,8 +1,6 @@ package v2alpha1 import ( - "errors" - conversion "k8s.io/apimachinery/pkg/conversion" klog "k8s.io/klog/v2" @@ -15,12 +13,7 @@ func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstruct out.Unstructured = *in err := migration.Migrate(out.Unstructured.Object, schemaversion.LATEST_VERSION) if err != nil { - minErr := &schemaversion.MinimumVersionError{} - if errors.As(err, &minErr) { - out.Unstructured.Object["__migrationError"] = err.Error() - } else { - return err - } + return err } t, ok := out.Unstructured.Object["title"].(string) From 5a74a1a0f6b795e943865f9ac2a473611578b855 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu, 13 Feb 2025 10:19:22 -0500 Subject: [PATCH 49/78] Metrics: Use correct gatherer in graphite bridge (#100624) --- pkg/infra/metrics/service.go | 6 ++++-- pkg/infra/metrics/settings.go | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/infra/metrics/service.go b/pkg/infra/metrics/service.go index f704ab9248b..99c1e048d4f 100644 --- a/pkg/infra/metrics/service.go +++ b/pkg/infra/metrics/service.go @@ -26,12 +26,13 @@ func (lw *logWrapper) Println(v ...any) { lw.logger.Info("graphite metric bridge", v...) } -func ProvideService(cfg *setting.Cfg, reg prometheus.Registerer) (*InternalMetricsService, error) { +func ProvideService(cfg *setting.Cfg, reg prometheus.Registerer, gatherer prometheus.Gatherer) (*InternalMetricsService, error) { initMetricVars(reg) initFrontendMetrics(reg) s := &InternalMetricsService{ - Cfg: cfg, + Cfg: cfg, + gatherer: gatherer, } return s, s.readSettings() } @@ -41,6 +42,7 @@ type InternalMetricsService struct { intervalSeconds int64 graphiteCfg *graphitebridge.Config + gatherer prometheus.Gatherer } func (im *InternalMetricsService) Run(ctx context.Context) error { diff --git a/pkg/infra/metrics/settings.go b/pkg/infra/metrics/settings.go index 54715db249e..587956158f2 100644 --- a/pkg/infra/metrics/settings.go +++ b/pkg/infra/metrics/settings.go @@ -5,8 +5,6 @@ import ( "strings" "time" - "github.com/prometheus/client_golang/prometheus" - "github.com/grafana/grafana/pkg/infra/metrics/graphitebridge" ) @@ -40,7 +38,7 @@ func (im *InternalMetricsService) parseGraphiteSettings() error { URL: address, Prefix: graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"), CountersAsDelta: true, - Gatherer: prometheus.DefaultGatherer, + Gatherer: im.gatherer, Interval: time.Duration(im.intervalSeconds) * time.Second, Timeout: 10 * time.Second, Logger: &logWrapper{logger: metricsLogger}, From 1f369074811feaddcf47f49902000086c294a8a8 Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Thu, 13 Feb 2025 16:27:55 +0100 Subject: [PATCH 50/78] Provisioning: Encrypt webhook secret (#100612) * feat: encrypt webhook secret * chore: regen code --- pkg/apis/provisioning/v0alpha1/types.go | 1 + .../provisioning/v0alpha1/zz_generated.deepcopy.go | 5 +++++ .../provisioning/v0alpha1/zz_generated.openapi.go | 6 ++++++ .../provisioning/v0alpha1/webhookstatus.go | 11 +++++++++++ pkg/registry/apis/provisioning/register.go | 9 +++++++++ pkg/registry/apis/provisioning/repository/github.go | 7 ++++++- .../provisioning.grafana.app-v0alpha1.json | 4 ++++ public/app/features/provisioning/api/endpoints.gen.ts | 1 + 8 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 7583af30dc1..5b5936d568a 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -207,6 +207,7 @@ type WebhookStatus struct { ID int64 `json:"id,omitempty"` URL string `json:"url,omitempty"` Secret string `json:"secret,omitempty"` + EncryptedSecret []byte `json:"encryptedSecret,omitempty"` SubscribedEvents []string `json:"subscribedEvents,omitempty"` } diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index a31190ecb79..fefbc6ea297 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -857,6 +857,11 @@ func (in *WebhookResponse) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WebhookStatus) DeepCopyInto(out *WebhookStatus) { *out = *in + if in.EncryptedSecret != nil { + in, out := &in.EncryptedSecret, &out.EncryptedSecret + *out = make([]byte, len(*in)) + copy(*out, *in) + } if in.SubscribedEvents != nil { in, out := &in.SubscribedEvents, &out.SubscribedEvents *out = make([]string, len(*in)) diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index fd73980698f..a53c4380bd0 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -1909,6 +1909,12 @@ func schema_pkg_apis_provisioning_v0alpha1_WebhookStatus(ref common.ReferenceCal Format: "", }, }, + "encryptedSecret": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "byte", + }, + }, "subscribedEvents": { SchemaProps: spec.SchemaProps{ Type: []string{"array"}, diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go index e42f7b9c134..d550e4fc753 100644 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go +++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/webhookstatus.go @@ -10,6 +10,7 @@ type WebhookStatusApplyConfiguration struct { ID *int64 `json:"id,omitempty"` URL *string `json:"url,omitempty"` Secret *string `json:"secret,omitempty"` + EncryptedSecret []byte `json:"encryptedSecret,omitempty"` SubscribedEvents []string `json:"subscribedEvents,omitempty"` } @@ -43,6 +44,16 @@ func (b *WebhookStatusApplyConfiguration) WithSecret(value string) *WebhookStatu return b } +// WithEncryptedSecret adds the given value to the EncryptedSecret field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the EncryptedSecret field. +func (b *WebhookStatusApplyConfiguration) WithEncryptedSecret(values ...byte) *WebhookStatusApplyConfiguration { + for i := range values { + b.EncryptedSecret = append(b.EncryptedSecret, values[i]) + } + return b +} + // WithSubscribedEvents adds the given value to the SubscribedEvents field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the SubscribedEvents field. diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 07b8cafbe79..a09100cf4c1 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -804,5 +804,14 @@ func (b *APIBuilder) encryptSecrets(ctx context.Context, repo *provisioning.Repo } repo.Spec.GitHub.Token = "" } + + if repo.Status.Webhook != nil && + repo.Status.Webhook.Secret != "" { + repo.Status.Webhook.EncryptedSecret, err = b.secrets.Encrypt(ctx, []byte(repo.Status.Webhook.Secret)) + if err != nil { + return err + } + repo.Status.Webhook.Secret = "" + } return nil } diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 38392403c0d..3b777aa46ed 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -525,7 +525,12 @@ func (r *githubRepository) Webhook(ctx context.Context, req *http.Request) (*pro return nil, fmt.Errorf("unexpected webhook request") } - payload, err := github.ValidatePayload(req, []byte(r.config.Status.Webhook.Secret)) + secret, err := r.secrets.Decrypt(ctx, r.config.Status.Webhook.EncryptedSecret) + if err != nil { + return nil, fmt.Errorf("failed to decrypt secret: %w", err) + } + + payload, err := github.ValidatePayload(req, secret) if err != nil { return nil, apierrors.NewUnauthorized("invalid signature") } diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index e42bcfce665..a3f821512cc 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -4007,6 +4007,10 @@ "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.WebhookStatus": { "type": "object", "properties": { + "encryptedSecret": { + "type": "string", + "format": "byte" + }, "id": { "type": "integer", "format": "int64" diff --git a/public/app/features/provisioning/api/endpoints.gen.ts b/public/app/features/provisioning/api/endpoints.gen.ts index db71cc177eb..af3482a40ed 100644 --- a/public/app/features/provisioning/api/endpoints.gen.ts +++ b/public/app/features/provisioning/api/endpoints.gen.ts @@ -829,6 +829,7 @@ export type SyncStatus = { state: 'error' | 'pending' | 'success' | 'working'; }; export type WebhookStatus = { + encryptedSecret?: string; id?: number; secret?: string; subscribedEvents?: string[]; From 1018aec6bcd0be36f9de4efda325fe189f28ccd3 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Thu, 13 Feb 2025 16:31:11 +0100 Subject: [PATCH 51/78] Dashboards: Fix repeats not being added on refresh when using searchLayout (#100621) Fix repeats not being added --- .../dashboard-scene/scene/PanelSearchLayout.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx index 780fab43b99..2026f777a6a 100644 --- a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx +++ b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import classNames from 'classnames'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneGridRow, VizPanel, sceneGraph } from '@grafana/scenes'; @@ -12,6 +12,7 @@ import { forceActivateFullSceneObjectTree } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; +import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; export interface Props { dashboard: DashboardScene; @@ -25,6 +26,7 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }: const { body } = dashboard.state; const filteredPanels: VizPanel[] = []; const styles = useStyles2(getStyles); + const [_, setRepeatsUpdated] = useState(''); const bodyGrid = body instanceof DefaultGridLayoutManager ? body.state.grid : null; @@ -34,11 +36,11 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }: for (const gridItem of bodyGrid.state.children) { if (gridItem instanceof DashboardGridItem) { - filterPanels(gridItem, dashboard, panelSearch, filteredPanels); + filterPanels(gridItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated); } else if (gridItem instanceof SceneGridRow) { for (const rowItem of gridItem.state.children) { if (rowItem instanceof DashboardGridItem) { - filterPanels(rowItem, dashboard, panelSearch, filteredPanels); + filterPanels(rowItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated); } } } @@ -98,7 +100,8 @@ function filterPanels( gridItem: DashboardGridItem, dashboard: DashboardScene, searchString: string, - filteredPanels: VizPanel[] + filteredPanels: VizPanel[], + setRepeatsUpdated: (updated: string) => void ) { const interpolatedSearchString = sceneGraph.interpolate(dashboard, searchString).toLowerCase(); @@ -107,6 +110,12 @@ function filterPanels( const panel = gridItem.state.body; const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase(); if (interpolatedTitle.includes(interpolatedSearchString)) { + gridItem.subscribeToEvent(DashboardRepeatsProcessedEvent, (event) => { + const source = event.payload.source; + if (source instanceof DashboardGridItem) { + setRepeatsUpdated(event.payload.source.state.key ?? ''); + } + }); gridItem.activate(); } } From cf78a43bd77bbc876396d37f3f4534b53d49b43c Mon Sep 17 00:00:00 2001 From: Roberto Jimenez Sanchez Date: Thu, 13 Feb 2025 13:05:04 +0100 Subject: [PATCH 52/78] Fix bug using unencrypted instead --- pkg/registry/apis/provisioning/repository/go-git/wrapper.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go index e15df09038e..9c541be6fbe 100644 --- a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go +++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go @@ -61,9 +61,9 @@ func Clone( return nil, fmt.Errorf("missing root config") } - decrypted, err := secrets.Decrypt(ctx, []byte(gitcfg.Token)) + decrypted, err := secrets.Decrypt(ctx, []byte(gitcfg.EncryptedToken)) if err != nil { - return nil, fmt.Errorf("error decrypting token %w", err) + return nil, fmt.Errorf("error decrypting token: %w", err) } err = os.MkdirAll(opts.Root, 0700) From 30939fd0e937f67b07f527af3d7e19da936dd43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irene=20Rodr=C3=ADguez?= Date: Thu, 13 Feb 2025 16:44:20 +0100 Subject: [PATCH 53/78] Update relrefs (#100626) --- .../explore/correlations-editor-in-explore.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/sources/explore/correlations-editor-in-explore.md b/docs/sources/explore/correlations-editor-in-explore.md index 50053226158..dd0a0ce1936 100644 --- a/docs/sources/explore/correlations-editor-in-explore.md +++ b/docs/sources/explore/correlations-editor-in-explore.md @@ -3,6 +3,7 @@ labels: products: - enterprise - oss + - cloud title: Correlations Editor in Explore weight: 20 --- @@ -13,22 +14,22 @@ weight: 20 The Explore editor is available in 10.1 and later versions. In the editor, transformations is available in Grafana 10.3 and later versions. {{% /admonition %}} -Correlations allow users to build a link between any two data sources. For more information about correlations in general, please see the [correlations]({{< relref "../administration/correlations" >}}) topic in the administration page. +Correlations allow users to build a link between any two data sources. For more information about correlations in general, please see the [correlations](/docs/grafana//administration/correlations/) topic in the administration page. ## Create a correlation 1. In Grafana, navigate to the Explore page. -1. Select a data source that you would like to be [the source data source]({{< relref "../administration/correlations/correlation-configuration#source-data-source-and-result-field" >}}) for a new correlation. -1. Run a query producing data in [a supported visualization]({{< relref "../administration/correlations#correlations" >}}). -1. Click **+ Add** in the top toolbar and select **Add correlation** (you can also select **Correlations Editor** from the [Command Palette]({{< relref "../search#command-palette" >}})). +1. Select a data source that you would like to be [the source data source](/docs/grafana//administration/correlations/correlation-configuration/#source-data-source-and-result-field) for a new correlation. +1. Run a query producing data in [a supported visualization](/docs/grafana//administration/correlations/#correlations). +1. Click **+ Add** in the top toolbar and select **Add correlation** (you can also select **Correlations Editor** from the [Command Palette](/docs/grafana//search/#command-palette)). 1. Explore is now in Correlations Editor mode indicated by a blue border and top bar. You can exit Correlations Editor by clicking **Exit** in the top bar. 1. You can now create the following new correlations for the visualization with links that are attached to the data that you can use to build a new query: - Logs: links are displayed next to field values inside log details for each log row - Table: every table cell is a link 1. Click on a link to add a new correlation. - Links are associated with a field that is used as a [result field of a correlation]({{< relref "../administration/correlations/correlation-configuration" >}}). -1. In the split view that opens, use the right pane to set up [the target query source of the correlation]({{< relref "../administration/correlations/correlation-configuration#target-query" >}}). -1. Build a target query using [variables syntax]({{< relref "../dashboards/variables/variable-syntax" >}}) with variables from the list provided at the top of the pane. The list contains sample values from the selected data row. + Links are associated with a field that is used as a [result field of a correlation](/docs/grafana//administration/correlations/correlation-configuration/). +1. In the split view that opens, use the right pane to set up [the target query source of the correlation](/docs/grafana//administration/correlations/correlation-configuration/#target-query). +1. Build a target query using [variables syntax](/docs/grafana//dashboards/variables/variable-syntax/) with variables from the list provided at the top of the pane. The list contains sample values from the selected data row. 1. Provide a label and description (optional). A label will be used as the name of the link inside the visualization and can contain variables. 1. Provide transformations (optional; see below for details). @@ -37,7 +38,7 @@ Correlations allow users to build a link between any two data sources. For more ## Transformations -Transformations allow you to extract values that exist in a field with other data. For example, using a transformation, you can extract one portion of a log line to use in a correlation. For more details on transformations in correlations, see [Correlations]({{< relref "../administration/correlations/correlation-configuration/#correlation-transformations" >}}). +Transformations allow you to extract values that exist in a field with other data. For example, using a transformation, you can extract one portion of a log line to use in a correlation. For more details on transformations in correlations, see [Correlations](/docs/grafana//explore/correlations-editor-in-explore/#transformations). After clicking one of the generated links in the editor mode, you can add transformations by clicking **Add transformation** in the Transformations dropdown menu. @@ -47,7 +48,7 @@ You can use a transformation in your correlation with the following steps: Select the portion of the field that you want to use for the transformation. For example, a log line. Once selected, the value of this field will be used to assist you in building the transformation. 1. Select the type of the transformation. - See [correlations]({{< relref "../administration/correlations/correlation-configuration/#correlation-transformations" >}}) for the options and relevant settings. + See [correlations](/docs/grafana//explore/correlations-editor-in-explore/#transformations) for the options and relevant settings. 1. Based on your selection, you might see one or more variables populate, or you might need to provide more specifications in options that are displayed. 1. Select **Add transformation to correlation** to add the specified variables to the list of available variables. @@ -57,7 +58,7 @@ For regular expressions in this dialog box, the `mapValue` referred to in other ## Correlations examples -The following examples show how to create correlations using the Correlations Editor in Explore. If you'd like to follow these examples, make sure to set up a [test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}). +The following examples show how to create correlations using the Correlations Editor in Explore. If you'd like to follow these examples, make sure to set up a [test data source](/docs/grafana//datasources/testdata/#testdata-data-source). ### Create a text to graph correlation @@ -65,7 +66,7 @@ This example shows how to create a correlation using Correlations Editor in Expl Correlations allow you to use results of one query to run a new query in any data source. In this example, you will run a query that renders tabular data. The data will be used to run a different query that yields a graph result. -To follow this example, make sure you have set up [a test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}). +To follow this example, make sure you have set up [a test data source](/docs/grafana//datasources/testdata/#testdata-data-source). 1. In Grafana, navigate to **Explore**. 1. Select the **test data source** from the dropdown menu at the top left of the page. @@ -100,7 +101,7 @@ You can apply the same steps to any data source. Correlations allow you to creat In this example, you will create a correlation to demonstrate how to use transformations to extract values from the log line and another field. -To follow this example, make sure you have set up [a test data source]({{< relref "../datasources/testdata#testdata-data-source" >}}). +To follow this example, make sure you have set up [a test data source](/docs/grafana//datasources/testdata/#testdata-data-source). 1. In Grafana, navigate to **Explore**. 1. Select the **test data source** from the dropdown menu at the top left of the page. From aeb57f671bfacf3cf30eddc24e1b9f489c17a6b8 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Thu, 13 Feb 2025 16:53:03 +0100 Subject: [PATCH 54/78] Docs: Improve instructions to change basic roles (#100586) --- .../plan-rbac-rollout-strategy/index.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md b/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md index 09c2fc4c3d4..e04c2fcecf2 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/index.md @@ -369,9 +369,11 @@ Here are two ways to achieve this: # Update the role curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' -H 'Content-Type: application/json' \ - -X PUT-d @/tmp/basic_viewer.json '/api/access-control/roles/basic_viewer' + -X PUT -d @/tmp/basic_viewer.json '/api/access-control/roles/basic_viewer' ``` + The token that is used in this request is the [service account token](ref:service-accounts). + - Or use the `role > from` list and `permission > state` option of your provisioning file: ```yaml @@ -394,6 +396,20 @@ Here are two ways to achieve this: state: 'present' ``` + If your goal is to remove an access to an app you should remove it from the role and update it. For example: + + ```bash + # Fetch the role, modify it to remove permissions to kentik-connect-app and increment role version + curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' \ + -X GET '/api/access-control/roles/basic_viewer' | \ + jq 'del(.created)| del(.updated) | del(.permissions[].created) | del(.permissions[].updated) | .version += 1' | \ + jq 'del(.permissions[] | select (.action == "plugins.app:access" and .scope == "plugins:id:kentik-connect-app"))' + + # Update the role + curl -H 'Authorization: Bearer glsa_kcVxDhZtu5ISOZIEt' -H 'Content-Type: application/json' \ + -X PUT -d @/tmp/basic_viewer.json '/api/access-control/roles/basic_viewer' + ``` + ### Manage user permissions through teams In the scenario where you want users to grant access by the team they belong to, we recommend to set users role to `No Basic Role` and let the team assignment assign the role instead. From 0dab3848267f0aa29c2314f3388df0af3af306c0 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 13 Feb 2025 18:55:36 +0300 Subject: [PATCH 55/78] K8s/Frontend: Update watch support (#100631) use watch from gitsync --- public/app/features/apiserver/client.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index 90a5004b8b0..f631aca175a 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -1,6 +1,6 @@ import { Observable, from, retry, catchError, filter, map, mergeMap } from 'rxjs'; -import { config, getBackendSrv } from '@grafana/runtime'; +import { BackendSrvRequest, config, getBackendSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; import { getAPINamespace } from '../../api/utils'; @@ -40,18 +40,26 @@ export class ScopedResourceClient implements return getBackendSrv().get>(`${this.url}/${name}`); } - public watch(opts?: WatchOptions): Observable> { + public watch( + params?: WatchOptions, + config?: Pick + ): Observable> { const decoder = new TextDecoder(); - const params = { - ...opts, + const { name, ...rest } = params ?? {}; // name needs to be added to fieldSelector + const requestParams = { + ...rest, watch: true, - labelSelector: this.parseListOptionsSelector(opts?.labelSelector), - fieldSelector: this.parseListOptionsSelector(opts?.fieldSelector), + labelSelector: this.parseListOptionsSelector(params?.labelSelector), + fieldSelector: this.parseListOptionsSelector(params?.fieldSelector), }; + if (name) { + requestParams.fieldSelector = `metadata.name=${name}`; + } return getBackendSrv() .chunked({ - url: params.name ? `${this.url}/${params.name}` : this.url, - params, + url: this.url, + params: requestParams, + ...config, }) .pipe( filter((response) => response.ok && response.data instanceof Uint8Array), From d719e6c621211116868735ab3df87a94dd0eecf8 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 13 Feb 2025 17:02:51 +0100 Subject: [PATCH 56/78] ServiceAccounts: Fix search in SA picker (#100634) --- public/app/core/components/Select/ServiceAccountPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/core/components/Select/ServiceAccountPicker.tsx b/public/app/core/components/Select/ServiceAccountPicker.tsx index 28c8e3441d2..b1c0e476891 100644 --- a/public/app/core/components/Select/ServiceAccountPicker.tsx +++ b/public/app/core/components/Select/ServiceAccountPicker.tsx @@ -38,7 +38,7 @@ export class ServiceAccountPicker extends Component { } return getBackendSrv() - .get(`/api/serviceaccounts/search`) + .get(`/api/serviceaccounts/search?query=${query}&perpage=100`) .then((result: ServiceAccountsState) => { return result.serviceAccounts.map((sa) => ({ id: sa.id, From 18a938cf037a1b65217797907618e9c2ec3fdf5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 13 Feb 2025 17:10:10 +0100 Subject: [PATCH 57/78] [Provisioning] Remove S3 Option (#100638) Remove S3 option --- pkg/apis/provisioning/v0alpha1/register.go | 2 - pkg/apis/provisioning/v0alpha1/types.go | 18 +-- .../v0alpha1/zz_generated.deepcopy.go | 21 --- .../v0alpha1/zz_generated.openapi.go | 45 +------ .../provisioning/v0alpha1/repositoryspec.go | 9 -- .../v0alpha1/s3repositoryconfig.go | 34 ----- pkg/generated/applyconfiguration/utils.go | 2 - pkg/registry/apis/provisioning/register.go | 2 - .../apis/provisioning/repository/s3.go | 121 ------------------ .../apis/provisioning/repository/test.go | 4 - .../provisioning.grafana.app-v0alpha1.json | 33 +---- .../apis/provisioning/provisioning_test.go | 1 - .../provisioning/testdata/s3-example.json | 22 ---- .../app/features/provisioning/ConfigForm.tsx | 12 +- .../provisioning/api/endpoints.gen.ts | 20 +-- 15 files changed, 22 insertions(+), 324 deletions(-) delete mode 100644 pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go delete mode 100644 pkg/registry/apis/provisioning/repository/s3.go delete mode 100644 pkg/tests/apis/provisioning/testdata/s3-example.json diff --git a/pkg/apis/provisioning/v0alpha1/register.go b/pkg/apis/provisioning/v0alpha1/register.go index 1438186062a..9e5eec0b6cc 100644 --- a/pkg/apis/provisioning/v0alpha1/register.go +++ b/pkg/apis/provisioning/v0alpha1/register.go @@ -39,8 +39,6 @@ var RepositoryResourceInfo = utils.NewResourceInfo(GROUP, VERSION, switch m.Spec.Type { case LocalRepositoryType: target = m.Spec.Local.Path - case S3RepositoryType: - target = m.Spec.S3.Bucket case GitHubRepositoryType: target = m.Spec.GitHub.URL } diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 5b5936d568a..ce2a7ef1dcc 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -23,15 +23,6 @@ type LocalRepositoryConfig struct { Path string `json:"path,omitempty"` } -type S3RepositoryConfig struct { - Region string `json:"region,omitempty"` - Bucket string `json:"bucket,omitempty"` - - // TODO: Add ACL? - // TODO: Encryption?? - // TODO: How do we define access? Secrets? -} - // Workflow used for changes in the repository. // +enum type Workflow string @@ -76,7 +67,6 @@ type RepositoryType string // RepositoryType values const ( LocalRepositoryType RepositoryType = "local" - S3RepositoryType RepositoryType = "s3" GitHubRepositoryType RepositoryType = "github" ) @@ -97,15 +87,11 @@ type RepositorySpec struct { Type RepositoryType `json:"type"` // The repository on the local file system. - // Mutually exclusive with local | s3 | github. + // Mutually exclusive with local | github. Local *LocalRepositoryConfig `json:"local,omitempty"` - // The repository in an S3 bucket. - // Mutually exclusive with local | s3 | github. - S3 *S3RepositoryConfig `json:"s3,omitempty"` - // The repository on GitHub. - // Mutually exclusive with local | s3 | github. + // Mutually exclusive with local | github. // TODO: github or just 'git'?? GitHub *GitHubRepositoryConfig `json:"github,omitempty"` } diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index fefbc6ea297..d46e1aab264 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -454,11 +454,6 @@ func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) { *out = new(LocalRepositoryConfig) **out = **in } - if in.S3 != nil { - in, out := &in.S3, &out.S3 - *out = new(S3RepositoryConfig) - **out = **in - } if in.GitHub != nil { in, out := &in.GitHub, &out.GitHub *out = new(GitHubRepositoryConfig) @@ -721,22 +716,6 @@ func (in *ResourceWrapper) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *S3RepositoryConfig) DeepCopyInto(out *S3RepositoryConfig) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3RepositoryConfig. -func (in *S3RepositoryConfig) DeepCopy() *S3RepositoryConfig { - if in == nil { - return nil - } - out := new(S3RepositoryConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SyncJobOptions) DeepCopyInto(out *SyncJobOptions) { *out = *in diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index a53c4380bd0..61123288566 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -43,7 +43,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceWrapper": schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref), - "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_S3RepositoryConfig(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncJobOptions": schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions": schema_pkg_apis_provisioning_v0alpha1_SyncOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncStatus": schema_pkg_apis_provisioning_v0alpha1_SyncStatus(ref), @@ -989,28 +988,22 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa }, "type": { SchemaProps: spec.SchemaProps{ - Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`", + Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`", Default: "", Type: []string{"string"}, Format: "", - Enum: []interface{}{"github", "local", "s3"}, + Enum: []interface{}{"github", "local"}, }, }, "local": { SchemaProps: spec.SchemaProps{ - Description: "The repository on the local file system. Mutually exclusive with local | s3 | github.", + Description: "The repository on the local file system. Mutually exclusive with local | github.", Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig"), }, }, - "s3": { - SchemaProps: spec.SchemaProps{ - Description: "The repository in an S3 bucket. Mutually exclusive with local | s3 | github.", - Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig"), - }, - }, "github": { SchemaProps: spec.SchemaProps{ - Description: "The repository on GitHub. Mutually exclusive with local | s3 | github.", + Description: "The repository on GitHub. Mutually exclusive with local | github.", Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig"), }, }, @@ -1019,7 +1012,7 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa }, }, Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"}, + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"}, } } @@ -1118,11 +1111,11 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa }, "type": { SchemaProps: spec.SchemaProps{ - Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`", + Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`", Default: "", Type: []string{"string"}, Format: "", - Enum: []interface{}{"github", "local", "s3"}, + Enum: []interface{}{"github", "local"}, }, }, "target": { @@ -1604,30 +1597,6 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC } } -func schema_pkg_apis_provisioning_v0alpha1_S3RepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "region": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "bucket": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - func schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go index 8cdedec2731..b0d4769286d 100644 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go +++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go @@ -17,7 +17,6 @@ type RepositorySpecApplyConfiguration struct { Sync *SyncOptionsApplyConfiguration `json:"sync,omitempty"` Type *provisioningv0alpha1.RepositoryType `json:"type,omitempty"` Local *LocalRepositoryConfigApplyConfiguration `json:"local,omitempty"` - S3 *S3RepositoryConfigApplyConfiguration `json:"s3,omitempty"` GitHub *GitHubRepositoryConfigApplyConfiguration `json:"github,omitempty"` } @@ -75,14 +74,6 @@ func (b *RepositorySpecApplyConfiguration) WithLocal(value *LocalRepositoryConfi return b } -// WithS3 sets the S3 field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the S3 field is set to the value of the last call. -func (b *RepositorySpecApplyConfiguration) WithS3(value *S3RepositoryConfigApplyConfiguration) *RepositorySpecApplyConfiguration { - b.S3 = value - return b -} - // WithGitHub sets the GitHub field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the GitHub field is set to the value of the last call. diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go deleted file mode 100644 index b33e875bd91..00000000000 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/s3repositoryconfig.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v0alpha1 - -// S3RepositoryConfigApplyConfiguration represents a declarative configuration of the S3RepositoryConfig type for use -// with apply. -type S3RepositoryConfigApplyConfiguration struct { - Region *string `json:"region,omitempty"` - Bucket *string `json:"bucket,omitempty"` -} - -// S3RepositoryConfigApplyConfiguration constructs a declarative configuration of the S3RepositoryConfig type for use with -// apply. -func S3RepositoryConfig() *S3RepositoryConfigApplyConfiguration { - return &S3RepositoryConfigApplyConfiguration{} -} - -// WithRegion sets the Region field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Region field is set to the value of the last call. -func (b *S3RepositoryConfigApplyConfiguration) WithRegion(value string) *S3RepositoryConfigApplyConfiguration { - b.Region = &value - return b -} - -// WithBucket sets the Bucket field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Bucket field is set to the value of the last call. -func (b *S3RepositoryConfigApplyConfiguration) WithBucket(value string) *S3RepositoryConfigApplyConfiguration { - b.Bucket = &value - return b -} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index bae4891ada9..3b96c0d01fd 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -34,8 +34,6 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &provisioningv0alpha1.RepositoryStatusApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ResourceCount"): return &provisioningv0alpha1.ResourceCountApplyConfiguration{} - case v0alpha1.SchemeGroupVersion.WithKind("S3RepositoryConfig"): - return &provisioningv0alpha1.S3RepositoryConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("SyncOptions"): return &provisioningv0alpha1.SyncOptionsApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("SyncStatus"): diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index a09100cf4c1..9072dca8bd2 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -332,8 +332,6 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor r.GetName(), ) return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL) - case provisioning.S3RepositoryType: - return repository.NewS3(r), nil default: return repository.NewUnknown(r), nil } diff --git a/pkg/registry/apis/provisioning/repository/s3.go b/pkg/registry/apis/provisioning/repository/s3.go deleted file mode 100644 index 7726c5b2b28..00000000000 --- a/pkg/registry/apis/provisioning/repository/s3.go +++ /dev/null @@ -1,121 +0,0 @@ -package repository - -import ( - "context" - "net/http" - - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/validation/field" - - provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" -) - -type s3Repository struct { - config *provisioning.Repository -} - -var _ Repository = (*s3Repository)(nil) - -func NewS3(config *provisioning.Repository) *s3Repository { - return &s3Repository{config} -} - -func (r *s3Repository) Config() *provisioning.Repository { - return r.config -} - -// Validate implements provisioning.Repository. -func (r *s3Repository) Validate() (list field.ErrorList) { - s3 := r.Config().Spec.S3 - if s3 == nil { - list = append(list, field.Required(field.NewPath("spec", "s3"), "an s3 config is required")) - return - } - if s3.Region == "" { - list = append(list, field.Required(field.NewPath("spec", "s3", "region"), "an s3 region is required")) - } - if s3.Bucket == "" { - list = append(list, field.Required(field.NewPath("spec", "s3", "bucket"), "an s3 bucket name is required")) - } - return -} - -// Test implements provisioning.Repository. -func (r *s3Repository) Test(ctx context.Context) (*provisioning.TestResults, error) { - return nil, &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "test is not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -// ReadResource implements provisioning.Repository. -func (r *s3Repository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) { - return nil, &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "read resource is not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -func (r *s3Repository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) { - return nil, &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "read file tree resource is not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -func (r *s3Repository) Create(ctx context.Context, path string, ref string, data []byte, comment string) error { - return &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "write file is not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -func (r *s3Repository) Update(ctx context.Context, path string, ref string, data []byte, comment string) error { - return &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "write file is not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -func (r *s3Repository) Delete(ctx context.Context, path string, ref string, comment string) error { - return &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "delete file not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -func (r *s3Repository) Write(ctx context.Context, path string, ref string, data []byte, message string) error { - return writeWithReadThenCreateOrUpdate(ctx, r, path, ref, data, message) -} - -func (r *s3Repository) History(ctx context.Context, path string, ref string) ([]provisioning.HistoryItem, error) { - return nil, &errors.StatusError{ - ErrStatus: metav1.Status{ - Message: "history is not yet implemented", - Code: http.StatusNotImplemented, - }, - } -} - -// Webhook implements Repository. -func (r *s3Repository) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) { - return nil, &errors.StatusError{ - ErrStatus: metav1.Status{ - Code: http.StatusNotImplemented, - Message: "webhook not implemented", - }, - } -} diff --git a/pkg/registry/apis/provisioning/repository/test.go b/pkg/registry/apis/provisioning/repository/test.go index 7e81129a1ff..3f5c14c7d0f 100644 --- a/pkg/registry/apis/provisioning/repository/test.go +++ b/pkg/registry/apis/provisioning/repository/test.go @@ -70,9 +70,5 @@ func ValidateRepository(repo Repository) field.ErrorList { cfg.Spec.GitHub, "Github config only valid when type is github")) } - if cfg.Spec.Type != provisioning.S3RepositoryType && cfg.Spec.S3 != nil { - list = append(list, field.Invalid(field.NewPath("spec", "s3"), - cfg.Spec.GitHub, "S3 config only valid when type is s3")) - } return list } diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index a3f821512cc..14a1df4d7e9 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -3308,7 +3308,7 @@ "type": "string" }, "github": { - "description": "The repository on GitHub. Mutually exclusive with local | s3 | github.", + "description": "The repository on GitHub. Mutually exclusive with local | github.", "allOf": [ { "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitHubRepositoryConfig" @@ -3316,7 +3316,7 @@ ] }, "local": { - "description": "The repository on the local file system. Mutually exclusive with local | s3 | github.", + "description": "The repository on the local file system. Mutually exclusive with local | github.", "allOf": [ { "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.LocalRepositoryConfig" @@ -3328,14 +3328,6 @@ "type": "boolean", "default": false }, - "s3": { - "description": "The repository in an S3 bucket. Mutually exclusive with local | s3 | github.", - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.S3RepositoryConfig" - } - ] - }, "sync": { "description": "Sync settings -- how values are pulled from the repository into grafana", "default": {}, @@ -3351,13 +3343,12 @@ "default": "" }, "type": { - "description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`", + "description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`", "type": "string", "default": "", "enum": [ "github", - "local", - "s3" + "local" ] } } @@ -3454,13 +3445,12 @@ "default": "" }, "type": { - "description": "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`", + "description": "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`", "type": "string", "default": "", "enum": [ "github", - "local", - "s3" + "local" ] } } @@ -3797,17 +3787,6 @@ } ] }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.S3RepositoryConfig": { - "type": "object", - "properties": { - "bucket": { - "type": "string" - }, - "region": { - "type": "string" - } - } - }, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.SyncJobOptions": { "type": "object", "required": [ diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 19e91cf7d05..7e37837e23e 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -136,7 +136,6 @@ func TestIntegrationProvisioning(t *testing.T) { "testdata/local-devenv.json", "testdata/local-tmp.json", "testdata/local-xxx.json", - "testdata/s3-example.json", } { t.Run(inputFilePath, func(t *testing.T) { input := helper.LoadYAMLOrJSONFile(inputFilePath) diff --git a/pkg/tests/apis/provisioning/testdata/s3-example.json b/pkg/tests/apis/provisioning/testdata/s3-example.json deleted file mode 100644 index 7af6a5954b5..00000000000 --- a/pkg/tests/apis/provisioning/testdata/s3-example.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "apiVersion": "provisioning.grafana.app/v0alpha1", - "kind": "Repository", - "metadata": { - "name": "s3-example" - }, - "spec": { - "description": "load resources from an S3 bucket", - "s3": { - "bucket": "my-bucket", - "region": "us-west-1" - }, - "sync": { - "enabled": false, - "target": "folder", - "intervalSeconds": 60 - }, - "readOnly": false, - "title": "S3 Example", - "type": "s3" - } -} \ No newline at end of file diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx index 1a47060bab5..b9a9d061259 100644 --- a/public/app/features/provisioning/ConfigForm.tsx +++ b/public/app/features/provisioning/ConfigForm.tsx @@ -25,7 +25,7 @@ import { useCreateOrUpdateRepository } from './hooks'; import { RepositoryFormData, WorkflowOption } from './types'; import { dataToSpec, specToData } from './utils/data'; -const typeOptions = ['GitHub', 'Local', 'S3'].map((label) => ({ label, value: label.toLowerCase() })); +const typeOptions = ['GitHub', 'Local'].map((label) => ({ label, value: label.toLowerCase() })); const targetOptions = [ { value: 'instance', label: 'Entire instance' }, { value: 'folder', label: 'Managed folder' }, @@ -201,16 +201,6 @@ export function ConfigForm({ data }: ConfigFormProps) { )} - {type === 's3' && ( -
      - - - - - - -
      - )}
      diff --git a/public/app/features/provisioning/api/endpoints.gen.ts b/public/app/features/provisioning/api/endpoints.gen.ts index af3482a40ed..5a3cf607fcf 100644 --- a/public/app/features/provisioning/api/endpoints.gen.ts +++ b/public/app/features/provisioning/api/endpoints.gen.ts @@ -751,10 +751,6 @@ export type GitHubRepositoryConfig = { export type LocalRepositoryConfig = { path?: string; }; -export type S3RepositoryConfig = { - bucket?: string; - region?: string; -}; export type SyncOptions = { /** Enabled must be saved as true before any sync job will run */ enabled: boolean; @@ -770,14 +766,12 @@ export type SyncOptions = { export type RepositorySpec = { /** Repository description */ description?: string; - /** The repository on GitHub. Mutually exclusive with local | s3 | github. */ + /** The repository on GitHub. Mutually exclusive with local | github. */ github?: GitHubRepositoryConfig; - /** The repository on the local file system. Mutually exclusive with local | s3 | github. */ + /** The repository on the local file system. Mutually exclusive with local | github. */ local?: LocalRepositoryConfig; /** ReadOnly repository does not allow any write commands */ readOnly: boolean; - /** The repository in an S3 bucket. Mutually exclusive with local | s3 | github. */ - s3?: S3RepositoryConfig; /** Sync settings -- how values are pulled from the repository into grafana */ sync: SyncOptions; /** The repository display name (shown in the UI) */ @@ -786,9 +780,8 @@ export type RepositorySpec = { Possible enum values: - `"github"` - - `"local"` - - `"s3"` */ - type: 'github' | 'local' | 's3'; + - `"local"` */ + type: 'github' | 'local'; }; export type HealthStatus = { /** When the health was checked last time */ @@ -1059,9 +1052,8 @@ export type RepositoryView = { Possible enum values: - `"github"` - - `"local"` - - `"s3"` */ - type: 'github' | 'local' | 's3'; + - `"local"` */ + type: 'github' | 'local'; }; export type RepositoryViewList = { /** 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 */ From 90eb499b781ca94ed39390f93515aa543c25b08d Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 13 Feb 2025 17:17:14 +0100 Subject: [PATCH 58/78] PublicDashboards: Fetch dashboard as Grafana (#100344) --- pkg/apimachinery/identity/context.go | 2 + .../publicdashboards/service/query.go | 104 +--- .../publicdashboards/service/query_test.go | 457 +----------------- .../publicdashboards/service/service.go | 7 +- 4 files changed, 25 insertions(+), 545 deletions(-) diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 627cace5d61..81a7f81de6c 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -75,12 +75,14 @@ func getWildcardPermissions(actions ...string) map[string][]string { // serviceIdentityPermissions is a list of wildcard permissions for provided actions. // We should add every action required "internally" here. var serviceIdentityPermissions = getWildcardPermissions( + "annotations:read", "folders:read", "folders:write", "folders:create", "dashboards:read", "dashboards:write", "dashboards:create", + "datasources:query", "datasources:read", "alert.provisioning:write", "alert.provisioning.secrets:read", diff --git a/pkg/services/publicdashboards/service/query.go b/pkg/services/publicdashboards/service/query.go index 3d8731e895d..446f74ab3b6 100644 --- a/pkg/services/publicdashboards/service/query.go +++ b/pkg/services/publicdashboards/service/query.go @@ -8,16 +8,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/tsdb/grafanads" ) @@ -37,8 +34,8 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to unmarshal dashboard annotations: %w", err) } - anonymousUser := buildAnonymousUser(ctx, dash, pd.features) - + // We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the annotations. + svcCtx, svcIdent := identity.WithServiceIdentity(ctx, dash.OrgID) uniqueEvents := make(map[int64]models.AnnotationEvent, 0) for _, anno := range annoDto.Annotations.List { // skip annotations that are not enabled or are not a grafana datasource @@ -51,7 +48,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT OrgID: dash.OrgID, DashboardID: dash.ID, DashboardUID: dash.UID, - SignedInUser: anonymousUser, + SignedInUser: svcIdent, } if anno.Target != nil { @@ -63,7 +60,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT } } - annotationItems, err := pd.AnnotationsRepo.Find(ctx, annoQuery) + annotationItems, err := pd.AnnotationsRepo.Find(svcCtx, annoQuery) if err != nil { return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to find annotations: %w", err) } @@ -139,8 +136,9 @@ func (pd *PublicDashboardServiceImpl) GetQueryDataResponse(ctx context.Context, return nil, models.ErrPanelQueriesNotFound.Errorf("GetQueryDataResponse: failed to extract queries from panel") } - anonymousUser := buildAnonymousUser(ctx, dashboard, pd.features) - res, err := pd.QueryDataService.QueryData(ctx, anonymousUser, skipDSCache, metricReq) + // We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the datasource. + svcCtx, svcIdent := identity.WithServiceIdentity(ctx, dashboard.OrgID) + res, err := pd.QueryDataService.QueryData(svcCtx, svcIdent, skipDSCache, metricReq) reqDatasources := metricReq.GetUniqueDatasourceTypes() if err != nil { @@ -180,92 +178,6 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(dashboard *dashboards.D }, nil } -// buildAnonymousUser creates a user with permissions to read from all datasources used in the dashboard -func buildAnonymousUser(ctx context.Context, dashboard *dashboards.Dashboard, features featuremgmt.FeatureToggles) *user.SignedInUser { - datasourceUids := getUniqueDashboardDatasourceUids(dashboard.Data) - - // Create a user with blank permissions - anonymousUser := &user.SignedInUser{OrgID: dashboard.OrgID, Permissions: make(map[int64]map[string][]string)} - - // Scopes needed for Annotation queries - annotationScopes := []string{accesscontrol.ScopeAnnotationsTypeDashboard} - // Need to access all dashboards since tags annotations span across all dashboards - dashboardScopes := []string{dashboards.ScopeDashboardsProvider.GetResourceAllScope()} - - // Scopes needed for datasource queries - queryScopes := make([]string, 0) - readScopes := make([]string, 0) - for _, uid := range datasourceUids { - scope := datasources.ScopeProvider.GetResourceScopeUID(uid) - queryScopes = append(queryScopes, scope) - readScopes = append(readScopes, scope) - } - - // Apply all scopes to the actions we need the user to be able to perform - permissions := make(map[string][]string) - permissions[datasources.ActionQuery] = queryScopes - permissions[datasources.ActionRead] = readScopes - permissions[dashboards.ActionDashboardsRead] = dashboardScopes - permissions[accesscontrol.ActionAnnotationsRead] = annotationScopes - - if features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) { - permissions[accesscontrol.ActionAnnotationsRead] = dashboardScopes - } - - anonymousUser.Permissions[dashboard.OrgID] = permissions - - return anonymousUser -} - -func getUniqueDashboardDatasourceUids(dashboard *simplejson.Json) []string { - var datasourceUids []string - exists := map[string]bool{} - - // collapsed rows contain panels in a nested structure, so we need to flatten them before calculate unique uids - flattenedPanels := getFlattenedPanels(dashboard) - - for _, panelObj := range flattenedPanels { - panel := simplejson.NewFromAny(panelObj) - uid := getDataSourceUidFromJson(panel) - - // if uid is for a mixed datasource, get the datasource uids from the targets - if uid == "-- Mixed --" { - for _, targetObj := range panel.Get("targets").MustArray() { - target := simplejson.NewFromAny(targetObj) - datasourceUid := getDataSourceUidFromJson(target) - if _, ok := exists[datasourceUid]; !ok { - datasourceUids = append(datasourceUids, datasourceUid) - exists[datasourceUid] = true - } - } - } else { - if _, ok := exists[uid]; !ok { - datasourceUids = append(datasourceUids, uid) - exists[uid] = true - } - } - } - - return datasourceUids -} - -func getFlattenedPanels(dashboard *simplejson.Json) []any { - var flatPanels []any - for _, panelObj := range dashboard.Get("panels").MustArray() { - panel := simplejson.NewFromAny(panelObj) - // if the panel is a row and it is collapsed, get the queries from the panels inside the row - // if it is not collapsed, the row does not have any panels - if panel.Get("type").MustString() == "row" { - if panel.Get("collapsed").MustBool() { - flatPanels = append(flatPanels, panel.Get("panels").MustArray()...) - } - } else { - flatPanels = append(flatPanels, panelObj) - } - } - return flatPanels -} - func groupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.Json { result := make(map[int64][]*simplejson.Json) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index f6aec672eac..0db1dfd08b0 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -11,8 +11,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" dashboard2 "github.com/grafana/grafana/pkg/kinds/dashboard" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" @@ -110,161 +110,6 @@ const ( "schemaVersion": 35 }` - dashboardWithMixedDatasource = ` -{ - "panels": [ - { - "datasource": { - "type": "datasource", - "uid": "-- Mixed --" - }, - "id": 1, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "abc123" - }, - "exemplar": true, - "expr": "go_goroutines{job=\"$job\"}", - "interval": "", - "legendFormat": "", - "refId": "A" - }, - { - "datasource": "6SOeCRrVk", - "exemplar": true, - "expr": "test{id=\"f0dd9b69-ad04-4342-8e79-ced8c245683b\", name=\"test\"}", - "hide": false, - "interval": "", - "legendFormat": "", - "refId": "B" - } - ], - "title": "Panel Title", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "id": 2, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "exemplar": true, - "expr": "go_goroutines{job=\"$job\"}", - "interval": "", - "legendFormat": "", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "id": 3, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "exemplar": true, - "expr": "go_goroutines{job=\"$job\"}", - "interval": "", - "legendFormat": "", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - } - ], - "schemaVersion": 35 -}` - - dashboardWithDuplicateDatasources = ` -{ - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "abc123" - }, - "id": 1, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "abc123" - }, - "exemplar": true, - "expr": "go_goroutines{job=\"$job\"}", - "interval": "", - "legendFormat": "", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "id": 2, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "exemplar": true, - "expr": "go_goroutines{job=\"$job\"}", - "interval": "", - "legendFormat": "", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "id": 3, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "_yxMP8Ynk" - }, - "exemplar": true, - "expr": "go_goroutines{job=\"$job\"}", - "interval": "", - "legendFormat": "", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - } - ], - "schemaVersion": 35 -}` - oldStyleDashboard = ` { "panels": [ @@ -460,218 +305,6 @@ const ( ], "schemaVersion": 35 }` - - dashboardWithCollapsedRows = ` -{ -"panels": [ - { - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 12, - "title": "Row title", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "qCbTUC37k" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 1 - }, - "id": 11, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "qCbTUC37k" - }, - "editorMode": "builder", - "expr": "access_evaluation_duration_bucket", - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - }, - { - "collapsed": true, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 9 - }, - "id": 10, - "panels": [ - { - "datasource": { - "type": "influxdb", - "uid": "P49A45DF074423DFB" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 10 - }, - "id": 8, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.4.0-pre", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "P49A45DF074423DFB" - }, - "query": "// v.bucket, v.timeRangeStart, and v.timeRange stop are all variables supported by the flux plugin and influxdb\nfrom(bucket: v.bucket)\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r[\"_value\"] >= 10 and r[\"_value\"] <= 20)", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - } - ], - "title": "Row title 1", - "type": "row" - } - ] -}` ) func TestGetQueryDataResponse(t *testing.T) { @@ -731,8 +364,7 @@ func TestGetQueryDataResponse(t *testing.T) { func TestFindAnnotations(t *testing.T) { color := "red" name := "annoName" - features := featuremgmt.WithFeatures(featuremgmt.FlagAnnotationPermissionUpdate) - t.Run("will build anonymous user with correct permissions to get annotations", func(t *testing.T) { + t.Run("service identity has correct permissions to get annotations dashboards and query datasources", func(t *testing.T) { fakeStore := &FakePublicDashboardStore{} fakeStore.On("FindByAccessToken", mock.Anything, mock.AnythingOfType("string")). Return(&PublicDashboard{Uid: "uid1", IsEnabled: true}, nil) @@ -746,11 +378,14 @@ func TestFindAnnotations(t *testing.T) { } dash := dashboards.NewDashboard("testDashboard") - items, _ := service.FindAnnotations(context.Background(), reqDTO, "abc123") - anonUser := buildAnonymousUser(context.Background(), dash, features) - - assert.Equal(t, "dashboards:*", anonUser.Permissions[0]["dashboards:read"][0]) + items, err := service.FindAnnotations(context.Background(), reqDTO, "abc123") + require.NoError(t, err) assert.Len(t, items, 0) + + _, svcIdent := identity.WithServiceIdentity(context.Background(), dash.OrgID) + require.Equal(t, "*", svcIdent.GetPermissions()["datasources:query"][0]) + require.Equal(t, "*", svcIdent.GetPermissions()["dashboards:read"][0]) + require.Equal(t, "*", svcIdent.GetPermissions()["annotations:read"][0]) }) t.Run("Test events from tag queries overwrite built-in annotation queries and duplicate events are not returned", func(t *testing.T) { @@ -1121,47 +756,6 @@ func TestGetMetricRequest(t *testing.T) { }) } -func TestGetUniqueDashboardDatasourceUids(t *testing.T) { - t.Run("can get unique datasource ids from dashboard", func(t *testing.T) { - json, err := simplejson.NewJson([]byte(dashboardWithDuplicateDatasources)) - require.NoError(t, err) - - uids := getUniqueDashboardDatasourceUids(json) - require.Len(t, uids, 2) - require.Equal(t, "abc123", uids[0]) - require.Equal(t, "_yxMP8Ynk", uids[1]) - }) - - t.Run("can get unique datasource ids from dashboard with a mixed datasource", func(t *testing.T) { - json, err := simplejson.NewJson([]byte(dashboardWithMixedDatasource)) - require.NoError(t, err) - - uids := getUniqueDashboardDatasourceUids(json) - require.Len(t, uids, 3) - require.Equal(t, "abc123", uids[0]) - require.Equal(t, "6SOeCRrVk", uids[1]) - require.Equal(t, "_yxMP8Ynk", uids[2]) - }) - - t.Run("can get no datasource uids from empty dashboard", func(t *testing.T) { - json, err := simplejson.NewJson([]byte(`{"panels": {}}`)) - require.NoError(t, err) - - uids := getUniqueDashboardDatasourceUids(json) - require.Len(t, uids, 0) - }) - - t.Run("can get unique datasource ids from dashboard with rows", func(t *testing.T) { - json, err := simplejson.NewJson([]byte(dashboardWithCollapsedRows)) - require.NoError(t, err) - - uids := getUniqueDashboardDatasourceUids(json) - require.Len(t, uids, 2) - require.Equal(t, "qCbTUC37k", uids[0]) - require.Equal(t, "P49A45DF074423DFB", uids[1]) - }) -} - func TestBuildMetricRequest(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil, fakeDashboardService, nil) @@ -1318,39 +912,6 @@ func TestBuildMetricRequest(t *testing.T) { }) } -func TestBuildAnonymousUser(t *testing.T) { - sqlStore, cfg := db.InitTestDBWithCfg(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) - features := featuremgmt.WithFeatures() - - t.Run("will add datasource read and query permissions to user for each datasource in dashboard", func(t *testing.T) { - user := buildAnonymousUser(context.Background(), dashboard, features) - - require.Equal(t, dashboard.OrgID, user.OrgID) - require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:query"][0]) - require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:query"][1]) - require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgID]["datasources:read"][0]) - require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgID]["datasources:read"][1]) - }) - t.Run("will add dashboard and annotation permissions needed for getting annotations", func(t *testing.T) { - user := buildAnonymousUser(context.Background(), dashboard, features) - - require.Equal(t, dashboard.OrgID, user.OrgID) - require.Equal(t, "annotations:type:dashboard", user.Permissions[user.OrgID]["annotations:read"][0]) - require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0]) - }) - t.Run("will add dashboard and annotation permissions needed for getting annotations when FlagAnnotationPermissionUpdate is enabled", func(t *testing.T) { - features = featuremgmt.WithFeatures(featuremgmt.FlagAnnotationPermissionUpdate) - user := buildAnonymousUser(context.Background(), dashboard, features) - - require.Equal(t, dashboard.OrgID, user.OrgID) - require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["annotations:read"][0]) - require.Equal(t, "dashboards:*", user.Permissions[user.OrgID]["dashboards:read"][0]) - }) -} - func TestGroupQueriesByPanelId(t *testing.T) { t.Run("can extract queries from dashboard with panel datasource string that has no datasource on panel targets", func(t *testing.T) { json, err := simplejson.NewJson([]byte(oldStyleDashboard)) diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index d3239e2857d..9223981518c 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -13,6 +13,7 @@ import ( "go.opentelemetry.io/otel" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -136,7 +137,11 @@ func (pd *PublicDashboardServiceImpl) Find(ctx context.Context, uid string) (*Pu func (pd *PublicDashboardServiceImpl) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*dashboards.Dashboard, error) { ctx, span := tracer.Start(ctx, "publicdashboards.FindDashboard") defer span.End() - dash, err := pd.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: dashboardUid, OrgID: orgId}) + + // We don't have a signed in user for public dashboards. We are using Grafana's Identity to query the dashboard. + dash, err := identity.WithServiceIdentityFn(ctx, orgId, func(ctx context.Context) (*dashboards.Dashboard, error) { + return pd.dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{UID: dashboardUid, OrgID: orgId}) + }) if err != nil { var dashboardErr dashboards.DashboardErr if ok := errors.As(err, &dashboardErr); ok { From 6e4c1a57c19c12d633fa1d72f19af30aa43db66d Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Thu, 13 Feb 2025 10:29:04 -0600 Subject: [PATCH 59/78] docs: capitalization issues (#100562) fixing two capitalization issues for product names. --- .../configure-notifications/manage-contact-points/_index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md b/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md index 160d76360dc..215ed1926dc 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/_index.md @@ -140,13 +140,13 @@ Each contact point integration has its own configuration options and setup proce - [Discord](ref:discord) - [Email](ref:email) - [Google Chat](ref:gchat) -- [Grafana Oncall](ref:oncall) +- [Grafana OnCall](ref:oncall) - Kafka REST Proxy - Line - [Microsoft Teams](ref:teams) - [MQTT](ref:mqtt) - [Opsgenie](ref:opsgenie) -- [Pagerduty](ref:pagerduty) +- [PagerDuty](ref:pagerduty) - Pushover - Sensu Go - [Slack](ref:slack) From 155492c8a5858330aba5f8a6a5168ebd5ec4cc94 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:35:53 -0300 Subject: [PATCH 60/78] search: handle "permission" query param in search (#100607) handle "permission" query param in search --- .../dashboard/legacysearcher/search_client.go | 19 +++++-------------- .../dashboards/service/dashboard_service.go | 4 ++++ pkg/storage/unified/resource/resource.pb.go | 16 +++++++++++++--- pkg/storage/unified/resource/resource.proto | 2 ++ pkg/storage/unified/search/bleve.go | 8 +++++++- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index bd2e4a896a8..c0e18288ea5 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/apis/dashboard" folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -40,9 +41,6 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour req.Query = strings.ReplaceAll(req.Query, "*", "") } - // TODO add missing support for the following query params: - // - folderIds (won't support, must use folderUIDs) - // - permission query := &dashboards.FindPersistedDashboardsQuery{ Title: req.Query, Limit: req.Limit, @@ -51,6 +49,10 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour IsDeleted: req.IsDeleted, } + if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) { + query.Permission = dashboardaccess.PERMISSION_EDIT + } + var queryType string if req.Options.Key.Resource == dashboard.DASHBOARD_RESOURCE { queryType = searchstore.TypeDashboard @@ -123,22 +125,11 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour } } - // TODO need to test this - // emptyResponse, err := a.dashService.GetSharedDashboardUIDsQuery(ctx, query) - - // if err != nil { - // return nil, err - // } else if emptyResponse { - // return nil, nil - // } - res, err := c.dashboardStore.FindDashboards(ctx, query) if err != nil { return nil, err } - // TODO sort if query.Sort == "" see sortedHits in services/search/service.go - searchFields := resource.StandardSearchFields() list := &resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 3964922fcda..ed8a8fa1d19 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -1755,6 +1755,10 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex request.IsDeleted = query.IsDeleted } + if query.Permission > 0 { + request.Permission = int64(query.Permission) + } + if query.Limit < 1 { query.Limit = 1000 } diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go index ee8801f361b..dc6593d385f 100644 --- a/pkg/storage/unified/resource/resource.pb.go +++ b/pkg/storage/unified/resource/resource.pb.go @@ -2016,6 +2016,7 @@ type ResourceSearchRequest struct { Explain bool `protobuf:"varint,9,opt,name=explain,proto3" json:"explain,omitempty"` IsDeleted bool `protobuf:"varint,10,opt,name=is_deleted,json=isDeleted,proto3" json:"is_deleted,omitempty"` Page int64 `protobuf:"varint,11,opt,name=page,proto3" json:"page,omitempty"` + Permission int64 `protobuf:"varint,12,opt,name=permission,proto3" json:"permission,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2127,6 +2128,13 @@ func (x *ResourceSearchRequest) GetPage() int64 { return 0 } +func (x *ResourceSearchRequest) GetPermission() int64 { + if x != nil { + return x.Permission + } + return 0 +} + type ResourceSearchResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Error details @@ -4238,8 +4246,8 @@ var file_resource_proto_rawDesc = string([]byte{ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xee, - 0x04, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x8e, + 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, @@ -4265,7 +4273,9 @@ var file_resource_proto_rawDesc = string([]byte{ 0x6c, 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12, + 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, 0x61, 0x63, diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto index f16f194d360..c11ed29f259 100644 --- a/pkg/storage/unified/resource/resource.proto +++ b/pkg/storage/unified/resource/resource.proto @@ -457,6 +457,8 @@ message ResourceSearchRequest { bool is_deleted = 10; int64 page = 11; + + int64 permission = 12; } message ResourceSearchResponse { diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index bf9748cf5bc..2b76bb97529 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -18,6 +18,7 @@ import ( "github.com/blevesearch/bleve/v2/search/query" bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" index "github.com/blevesearch/bleve_index_api" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/selection" @@ -611,11 +612,16 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res if !ok { return nil, resource.AsErrorResult(fmt.Errorf("missing auth info")) } + verb := utils.VerbList + if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) { + verb = utils.VerbPatch + } + checker, err := access.Compile(ctx, auth, authlib.ListRequest{ Namespace: b.key.Namespace, Group: b.key.Group, Resource: b.key.Resource, - Verb: utils.VerbList, + Verb: verb, }) if err != nil { return nil, resource.AsErrorResult(err) From 2bdeb727cfa56859e94e0474d7b19b923956eaee Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Thu, 13 Feb 2025 16:36:16 +0000 Subject: [PATCH 61/78] Chore: Bump react-router to v5.3.4 (#100500) --- package.json | 4 ++-- packages/grafana-ui/package.json | 2 +- yarn.lock | 40 +++++++++++--------------------- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 4d638dc7b9b..150751ba6e4 100644 --- a/package.json +++ b/package.json @@ -380,8 +380,8 @@ "react-redux": "9.2.0", "react-resizable": "3.0.5", "react-responsive-carousel": "^3.2.23", - "react-router": "5.3.3", - "react-router-dom": "5.3.3", + "react-router": "5.3.4", + "react-router-dom": "5.3.4", "react-router-dom-v5-compat": "^6.26.1", "react-select": "5.10.0", "react-split-pane": "0.1.92", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index cee844a1b5d..eff1830e872 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -97,7 +97,7 @@ "react-i18next": "^15.0.0", "react-inlinesvg": "4.1.5", "react-loading-skeleton": "3.5.0", - "react-router-dom": "5.3.3", + "react-router-dom": "5.3.4", "react-router-dom-v5-compat": "^6.26.1", "react-select": "5.10.0", "react-table": "7.8.0", diff --git a/yarn.lock b/yarn.lock index f818433fbda..f1092de32f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4124,7 +4124,7 @@ __metadata: react-i18next: "npm:^15.0.0" react-inlinesvg: "npm:4.1.5" react-loading-skeleton: "npm:3.5.0" - react-router-dom: "npm:5.3.3" + react-router-dom: "npm:5.3.4" react-router-dom-v5-compat: "npm:^6.26.1" react-select: "npm:5.10.0" react-select-event: "npm:^5.1.0" @@ -18399,8 +18399,8 @@ __metadata: react-refresh: "npm:0.14.0" react-resizable: "npm:3.0.5" react-responsive-carousel: "npm:^3.2.23" - react-router: "npm:5.3.3" - react-router-dom: "npm:5.3.3" + react-router: "npm:5.3.4" + react-router-dom: "npm:5.3.4" react-router-dom-v5-compat: "npm:^6.26.1" react-select: "npm:5.10.0" react-select-event: "npm:5.5.1" @@ -22463,19 +22463,6 @@ __metadata: languageName: node linkType: hard -"mini-create-react-context@npm:^0.4.0": - version: 0.4.1 - resolution: "mini-create-react-context@npm:0.4.1" - dependencies: - "@babel/runtime": "npm:^7.12.1" - tiny-warning: "npm:^1.0.3" - peerDependencies: - prop-types: ^15.0.0 - react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - checksum: 10/c816c785b7dccd67fdfa6a5edc673363b11845b6abca8a9d9f3ffa74520266d979b56f5db0dfc62ed912a90553c15be28c816311fc9c7856ab66a81d461d50e6 - languageName: node - linkType: hard - "mini-css-extract-plugin@npm:2.9.2": version: 2.9.2 resolution: "mini-css-extract-plugin@npm:2.9.2" @@ -26768,20 +26755,20 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:5.3.3": - version: 5.3.3 - resolution: "react-router-dom@npm:5.3.3" +"react-router-dom@npm:5.3.4": + version: 5.3.4 + resolution: "react-router-dom@npm:5.3.4" dependencies: "@babel/runtime": "npm:^7.12.13" history: "npm:^4.9.0" loose-envify: "npm:^1.3.1" prop-types: "npm:^15.6.2" - react-router: "npm:5.3.3" + react-router: "npm:5.3.4" tiny-invariant: "npm:^1.0.2" tiny-warning: "npm:^1.0.0" peerDependencies: react: ">=15" - checksum: 10/49552596f1a4c753b99324a5f4345b3ee91fbb780aa65851a7113f053044ef96c083d2ded12937e593b23a0fcdf58b9e49780df6bf6e27d9eeb348b3c85ae611 + checksum: 10/5e0696ae2d86f466ff700944758a227e1dcd79b48797d567776506e4e3b4a08b81336155feb86a33be9f38c17c4d3d94212b5c60c8ee9a086022e4fd3961db29 languageName: node linkType: hard @@ -26798,15 +26785,14 @@ __metadata: languageName: node linkType: hard -"react-router@npm:5.3.3": - version: 5.3.3 - resolution: "react-router@npm:5.3.3" +"react-router@npm:5.3.4": + version: 5.3.4 + resolution: "react-router@npm:5.3.4" dependencies: "@babel/runtime": "npm:^7.12.13" history: "npm:^4.9.0" hoist-non-react-statics: "npm:^3.1.0" loose-envify: "npm:^1.3.1" - mini-create-react-context: "npm:^0.4.0" path-to-regexp: "npm:^1.7.0" prop-types: "npm:^15.6.2" react-is: "npm:^16.6.0" @@ -26814,7 +26800,7 @@ __metadata: tiny-warning: "npm:^1.0.0" peerDependencies: react: ">=15" - checksum: 10/4631eed91020c73950804c7c7454e74b2eb495f803c5ca60c8b5572ca72cc06e336f3b08d9ee3fa730128a52c4d9e16d1aa7e8b7f85560629117e16d99a01cef + checksum: 10/99d54a99af6bc6d7cad2e5ea7eee9485b62a8b8e16a1182b18daa7fad7dafa5e526850eaeebff629848b297ae055a9cb5b4aba8760e81af8b903efc049d48f5c languageName: node linkType: hard @@ -30309,7 +30295,7 @@ __metadata: languageName: node linkType: hard -"tiny-warning@npm:^1.0.0, tiny-warning@npm:^1.0.3": +"tiny-warning@npm:^1.0.0": version: 1.0.3 resolution: "tiny-warning@npm:1.0.3" checksum: 10/da62c4acac565902f0624b123eed6dd3509bc9a8d30c06e017104bedcf5d35810da8ff72864400ad19c5c7806fc0a8323c68baf3e326af7cb7d969f846100d71 From b58b5b5768fc34a81d8e2bf4d23150ae3f287f8a Mon Sep 17 00:00:00 2001 From: Georges Chaudy Date: Thu, 13 Feb 2025 17:39:33 +0100 Subject: [PATCH 62/78] grpc: improve grpc logger (#100606) use proper grpc logging --- .../grpcserver/interceptors/logging.go | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/pkg/services/grpcserver/interceptors/logging.go b/pkg/services/grpcserver/interceptors/logging.go index 2a3997a7024..db4f017d1e4 100644 --- a/pkg/services/grpcserver/interceptors/logging.go +++ b/pkg/services/grpcserver/interceptors/logging.go @@ -2,27 +2,34 @@ package interceptors import ( "context" + "fmt" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" "google.golang.org/grpc" ) -func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor { - return func( - ctx context.Context, - req any, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, - ) (resp any, err error) { - resp, err = handler(ctx, req) - if enabled { - ctxLogger := logger.FromContext(ctx) - if err != nil { - ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err) - } else { - ctxLogger.Info("gRPC call", "method", info.FullMethod, "req", req, "resp", resp) - } +func InterceptorLogger(l log.Logger, enabled bool) logging.Logger { + return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) { + if !enabled { + return } - return resp, err - } + l := l.FromContext(ctx) + switch lvl { + case logging.LevelDebug: + l.Debug(msg, fields...) + case logging.LevelInfo: + l.Info(msg, fields...) + case logging.LevelWarn: + l.Warn(msg, fields...) + case logging.LevelError: + l.Error(msg, fields...) + default: + panic(fmt.Sprintf("unknown level %v", lvl)) + } + }) +} + +func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor { + return logging.UnaryServerInterceptor(InterceptorLogger(logger, enabled)) } From 5315b4fd2df445584bec3748d49c1e7227ad0e47 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 13 Feb 2025 18:41:09 +0200 Subject: [PATCH 63/78] Dashboard: Fix repeats behavior for inspect, solo panel and repeated and empty panels (#100605) --- e2e/old-arch/various-suite/solo-route.spec.ts | 4 +-- e2e/various-suite/solo-route.spec.ts | 4 +-- package.json | 4 +-- .../scene/DashboardSceneUrlSync.ts | 32 ++++++++++++++++--- .../DefaultGridLayoutManager.tsx | 8 +++++ .../dashboard-scene/utils/clone.test.ts | 2 ++ .../features/dashboard-scene/utils/utils.ts | 13 ++++++-- yarn.lock | 22 ++++++------- 8 files changed, 66 insertions(+), 23 deletions(-) diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts index 9717baf41bd..415257ead7c 100644 --- a/e2e/old-arch/various-suite/solo-route.spec.ts +++ b/e2e/old-arch/various-suite/solo-route.spec.ts @@ -25,7 +25,7 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' ); e2e.components.Panels.Panel.title('server=A').should('exist'); @@ -38,7 +38,7 @@ describe('Solo Route', () => { 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); + e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 9717baf41bd..415257ead7c 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -25,7 +25,7 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' ); e2e.components.Panels.Panel.title('server=A').should('exist'); @@ -38,7 +38,7 @@ describe('Solo Route', () => { 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); + e2e.components.Panels.Panel.title('server = B, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/package.json b/package.json index 150751ba6e4..7e889ddedde 100644 --- a/package.json +++ b/package.json @@ -275,8 +275,8 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "6.0.1", - "@grafana/scenes-react": "6.0.1", + "@grafana/scenes": "6.0.2", + "@grafana/scenes-react": "6.0.2", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 3493be625f9..cde4c8df0e0 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -22,7 +22,8 @@ import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutMana import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { - private _eventSub?: Unsubscribable; + private _viewEventSub?: Unsubscribable; + private _inspectEventSub?: Unsubscribable; constructor(private _scene: DashboardScene) {} @@ -78,6 +79,14 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { if (typeof values.inspect === 'string') { let panel = findVizPanelByKey(this._scene, values.inspect); if (!panel) { + // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet + // Here we check if the key contains the clone key so we force the repeat processing + // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key + if (containsCloneKey(values.inspect)) { + this._handleInspectRepeatClone(values.inspect); + return; + } + appEvents.emit(AppEvents.alertError, ['Panel not found']); locationService.partial({ inspect: null }); return; @@ -177,12 +186,27 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } } + private _handleInspectRepeatClone(inspect: string) { + if (!this._inspectEventSub) { + this._inspectEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { + const panel = findVizPanelByKey(this._scene, inspect); + if (panel) { + this._inspectEventSub?.unsubscribe(); + this._scene.setState({ + inspectPanelKey: inspect, + overlay: new PanelInspectDrawer({ panelRef: panel.getRef() }), + }); + } + }); + } + } + private _handleViewRepeatClone(viewPanel: string) { - if (!this._eventSub) { - this._eventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { + if (!this._viewEventSub) { + this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { const panel = findVizPanelByKey(this._scene, viewPanel); if (panel) { - this._eventSub?.unsubscribe(); + this._viewEventSub?.unsubscribe(); this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) }); } }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 97b268757d9..bad747f55de 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -252,6 +252,14 @@ export class DefaultGridLayoutManager } public activateRepeaters() { + if (!this.isActive) { + this.activate(); + } + + if (!this.state.grid.isActive) { + this.state.grid.activate(); + } + this.state.grid.forEachChild((child) => { if (child instanceof DashboardGridItem && !child.isActive) { child.activate(); diff --git a/public/app/features/dashboard-scene/utils/clone.test.ts b/public/app/features/dashboard-scene/utils/clone.test.ts index 58dcef6fb38..97b0e377e6a 100644 --- a/public/app/features/dashboard-scene/utils/clone.test.ts +++ b/public/app/features/dashboard-scene/utils/clone.test.ts @@ -30,6 +30,8 @@ describe('clone', () => { expect(getOriginalKey('panel-clone-1')).toBe('panel'); expect(getOriginalKey('row-clone-1/panel-clone-2')).toBe('panel'); expect(getOriginalKey('tab-clone-0/row-clone-1/panel-clone-2')).toBe('panel'); + expect(getOriginalKey('panel-2-clone-3')).toBe('panel-2'); + expect(getOriginalKey('panel-2')).toBe('panel-2'); }); }); diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 27ecaac2fe2..5e52a1d517f 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -21,7 +21,7 @@ import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; -import { getLastKeyFromClone, getOriginalKey } from './clone'; +import { getOriginalKey, isClonedKey } from './clone'; export const NEW_PANEL_HEIGHT = 8; export const NEW_PANEL_WIDTH = 12; @@ -64,7 +64,16 @@ function findVizPanelInternal(scene: SceneObject, key: string | undefined): VizP const panel = sceneGraph.findObject(scene, (obj) => { const objKey = obj.state.key!; - if (objKey === key || getLastKeyFromClone(objKey) === getLastKeyFromClone(key) || getOriginalKey(objKey) === key) { + if (objKey === key) { + return true; + } + + // It might be possible to have the keys changed in the meantime from `panel-2` to `panel-2-clone-0` + // We need to check this as well + const originalObjectKey = !isClonedKey(objKey) ? getOriginalKey(objKey) : objKey; + const originalKey = !isClonedKey(key) ? getOriginalKey(key) : key; + + if (originalObjectKey === originalKey) { return true; } diff --git a/yarn.lock b/yarn.lock index f1092de32f7..49162fcfe61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3814,11 +3814,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:6.0.1": - version: 6.0.1 - resolution: "@grafana/scenes-react@npm:6.0.1" +"@grafana/scenes-react@npm:6.0.2": + version: 6.0.2 + resolution: "@grafana/scenes-react@npm:6.0.2" dependencies: - "@grafana/scenes": "npm:6.0.1" + "@grafana/scenes": "npm:6.0.2" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3830,13 +3830,13 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/e4ad83cc628f17232fe9c8d74f641c65e2e289c177ce88a6990d00f6bea4e1a091115e7b98200de7bcff14ace0fe20eb816141fe533fee7d2ad5f7f665404d2c + checksum: 10/9744e01f2ff912229e43cedfa41d626ccdfd034f5b9718b57c593bc90edadade960f76baf1d8ad19eed03709c17c62397df1871b89acc635172aa14f6a20e096 languageName: node linkType: hard -"@grafana/scenes@npm:6.0.1": - version: 6.0.1 - resolution: "@grafana/scenes@npm:6.0.1" +"@grafana/scenes@npm:6.0.2": + version: 6.0.2 + resolution: "@grafana/scenes@npm:6.0.2" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3854,7 +3854,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/6862e57358ba2e63f139e7f3bb977b19945f67eb070aa2c85c073a55dc460d3ccfeecfee22aea92c660a7632ac997e6cd945f9466b64103436a221979e6e8fcb + checksum: 10/2584f296db6299ef0a09d51f5c267ebcf7e44bd17b4d6516e38d3220f8f1d7aebc63c5fc6523979c4ac4d3f555416ca573e85e03bd36eb33a11941a5b3497149 languageName: node linkType: hard @@ -18151,8 +18151,8 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:6.0.1" - "@grafana/scenes-react": "npm:6.0.1" + "@grafana/scenes": "npm:6.0.2" + "@grafana/scenes-react": "npm:6.0.2" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" From 19777ba3e99bb40e7db1b5c9f92021d85deefac8 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:49:21 +0100 Subject: [PATCH 64/78] Skip flaky test that's breaking the CI pipelines (#100640) --- pkg/tests/alertmanager/alertmanager_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/tests/alertmanager/alertmanager_test.go b/pkg/tests/alertmanager/alertmanager_test.go index 0f127ea2ab6..269e53075e2 100644 --- a/pkg/tests/alertmanager/alertmanager_test.go +++ b/pkg/tests/alertmanager/alertmanager_test.go @@ -13,6 +13,7 @@ func TestAlertmanagerIntegration_ExtraDedupStage(t *testing.T) { } t.Run("assert no flapping alerts when stopOnExtraDedup is enabled", func(t *testing.T) { + t.Skip("skipping flaky test") s, err := NewAlertmanagerScenario() require.NoError(t, err) defer s.Close() From eeadb7e771f18fa62dc6b5d0cb757666cde43aeb Mon Sep 17 00:00:00 2001 From: xavi <114113189+volcanonoodle@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:02:54 +0100 Subject: [PATCH 65/78] IAM: Log error when malformed json arrays are found in SSO configs (#99896) --- pkg/login/social/connectors/azuread_oauth.go | 20 +++++++++--- pkg/login/social/connectors/common.go | 25 ++++++++++++++- pkg/login/social/connectors/generic_oauth.go | 31 ++++++++++++++++--- pkg/login/social/connectors/github_oauth.go | 30 ++++++++++++++---- pkg/login/social/connectors/gitlab_oauth.go | 2 +- pkg/login/social/connectors/google_oauth.go | 2 +- .../social/connectors/grafana_com_oauth.go | 20 +++++++++--- pkg/login/social/connectors/okta_oauth.go | 2 +- pkg/login/social/socialimpl/service.go | 4 +-- pkg/util/strings.go | 17 +++++++--- public/app/features/auth-config/utils/data.ts | 5 ++- 11 files changed, 128 insertions(+), 30 deletions(-) diff --git a/pkg/login/social/connectors/azuread_oauth.go b/pkg/login/social/connectors/azuread_oauth.go index 0f4a72ed5a7..8ae1f3380ab 100644 --- a/pkg/login/social/connectors/azuread_oauth.go +++ b/pkg/login/social/connectors/azuread_oauth.go @@ -88,10 +88,17 @@ type keySetJWKS struct { } func NewAzureADProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles, cache remotecache.CacheStorage) *SocialAzureAD { + s := newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg) + + allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.AzureADProviderName, "error", err) + } + provider := &SocialAzureAD{ - SocialBase: newSocialBase(social.AzureADProviderName, orgRoleMapper, info, features, cfg), + SocialBase: s, cache: cache, - allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), + allowedOrganizations: allowedOrganizations, forceUseGraphAPI: MustBool(info.Extra[forceUseGraphAPIKey], ExtraAzureADSettingKeys[forceUseGraphAPIKey].DefaultValue.(bool)), } @@ -236,7 +243,7 @@ func (s *SocialAzureAD) managedIdentityCallback(ctx context.Context) (string, er } func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.AzureADProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } @@ -250,7 +257,12 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin appendUniqueScope(s.Config, social.OfflineAccessScope) } - s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.AzureADProviderName, "error", err) + } + + s.allowedOrganizations = allowedOrganizations s.forceUseGraphAPI = MustBool(newInfo.Extra[forceUseGraphAPIKey], false) return nil diff --git a/pkg/login/social/connectors/common.go b/pkg/login/social/connectors/common.go index 255b96742e8..15fc590f359 100644 --- a/pkg/login/social/connectors/common.go +++ b/pkg/login/social/connectors/common.go @@ -2,6 +2,7 @@ package connectors import ( "context" + "errors" "fmt" "io" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/mitchellh/mapstructure" "golang.org/x/oauth2" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -165,9 +167,25 @@ func MustBool(value any, defaultValue bool) bool { return result } +// CreateOAuthInfoFromKeyValuesWithLogging creates an OAuthInfo struct from a map[string]any using mapstructure +// it puts all extra key values into OAuthInfo's Extra map. +// It logs as errors any parsing errors that are not critical +func CreateOAuthInfoFromKeyValuesWithLogging(l log.Logger, provider string, settingsKV map[string]any) (*social.OAuthInfo, error) { + parsingWarns := []error{} + info, err := createOAuthInfoFromKeyValues(settingsKV, &parsingWarns) + if len(parsingWarns) > 0 { + l.Error("Invalid auth configuration setting", "error", errors.Join(parsingWarns...), "provider", provider) + } + return info, err +} + // CreateOAuthInfoFromKeyValues creates an OAuthInfo struct from a map[string]any using mapstructure // it puts all extra key values into OAuthInfo's Extra map func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo, error) { + return createOAuthInfoFromKeyValues(settingsKV, nil) +} + +func createOAuthInfoFromKeyValues(settingsKV map[string]any, parsingWarns *[]error) (*social.OAuthInfo, error) { emptyStrToSliceDecodeHook := func(from reflect.Type, to reflect.Type, data any) (any, error) { if from.Kind() == reflect.String && to.Kind() == reflect.Slice { strData, ok := data.(string) @@ -178,7 +196,12 @@ func CreateOAuthInfoFromKeyValues(settingsKV map[string]any) (*social.OAuthInfo, if strData == "" { return []string{}, nil } - return util.SplitString(strData), nil + + splitStr, err := util.SplitStringWithError(strData) + if err != nil && parsingWarns != nil { + *parsingWarns = append(*parsingWarns, err) + } + return splitStr, nil } return data, nil } diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index eb4a32f8381..15989c0df93 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -53,6 +53,18 @@ type SocialGenericOAuth struct { } func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGenericOAuth { + s := newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg) + + teamIds, err := util.SplitStringWithError(info.Extra[teamIdsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GenericOAuthProviderName, "error", err) + } + + allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GenericOAuthProviderName, "error", err) + } + provider := &SocialGenericOAuth{ SocialBase: newSocialBase(social.GenericOAuthProviderName, orgRoleMapper, info, features, cfg), teamsUrl: info.TeamsUrl, @@ -63,8 +75,8 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMa loginAttributePath: info.Extra[loginAttributePathKey], idTokenAttributeName: info.Extra[idTokenAttributeNameKey], teamIdsAttributePath: info.TeamIdsAttributePath, - teamIds: util.SplitString(info.Extra[teamIdsKey]), - allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), + teamIds: teamIds, + allowedOrganizations: allowedOrganizations, } if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { @@ -118,7 +130,7 @@ func validateTeamsUrlWhenNotEmpty(info *social.OAuthInfo, requester identity.Req } func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GenericOAuthProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } @@ -128,6 +140,15 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS s.updateInfo(ctx, social.GenericOAuthProviderName, newInfo) + teamIds, err := util.SplitStringWithError(newInfo.Extra[teamIdsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GenericOAuthProviderName, "error", err) + } + allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GenericOAuthProviderName, "error", err) + } + s.teamsUrl = newInfo.TeamsUrl s.emailAttributeName = newInfo.EmailAttributeName s.emailAttributePath = newInfo.EmailAttributePath @@ -136,8 +157,8 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS s.loginAttributePath = newInfo.Extra[loginAttributePathKey] s.idTokenAttributeName = newInfo.Extra[idTokenAttributeNameKey] s.teamIdsAttributePath = newInfo.TeamIdsAttributePath - s.teamIds = util.SplitString(newInfo.Extra[teamIdsKey]) - s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + s.teamIds = teamIds + s.allowedOrganizations = allowedOrganizations return nil } diff --git a/pkg/login/social/connectors/github_oauth.go b/pkg/login/social/connectors/github_oauth.go index 124b642f822..f5f0b43b3f3 100644 --- a/pkg/login/social/connectors/github_oauth.go +++ b/pkg/login/social/connectors/github_oauth.go @@ -62,13 +62,23 @@ var ( ) func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGithub { - teamIdsSplitted := util.SplitString(info.Extra[teamIdsKey]) + s := newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg) + + teamIdsSplitted, err := util.SplitStringWithError(info.Extra[teamIdsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GitHubProviderName, "error", err) + } teamIds := mustInts(teamIdsSplitted) + allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GitHubProviderName, "error", err) + } + provider := &SocialGithub{ - SocialBase: newSocialBase(social.GitHubProviderName, orgRoleMapper, info, features, cfg), + SocialBase: s, teamIds: teamIds, - allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), + allowedOrganizations: allowedOrganizations, } if len(teamIdsSplitted) != len(teamIds) { @@ -117,14 +127,22 @@ func teamIdsNumbersValidator(info *social.OAuthInfo, requester identity.Requeste } func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GitHubProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } - teamIdsSplitted := util.SplitString(newInfo.Extra[teamIdsKey]) + teamIdsSplitted, err := util.SplitStringWithError(newInfo.Extra[teamIdsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", teamIdsKey, "provider", social.GitHubProviderName, "error", err) + } teamIds := mustInts(teamIdsSplitted) + allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GitHubProviderName, "error", err) + } + if len(teamIdsSplitted) != len(teamIds) { s.log.Warn("Failed to parse team ids. Team ids must be a list of numbers.", "teamIds", teamIdsSplitted) } @@ -135,7 +153,7 @@ func (s *SocialGithub) Reload(ctx context.Context, settings ssoModels.SSOSetting s.updateInfo(ctx, social.GitHubProviderName, newInfo) s.teamIds = teamIds - s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + s.allowedOrganizations = allowedOrganizations return nil } diff --git a/pkg/login/social/connectors/gitlab_oauth.go b/pkg/login/social/connectors/gitlab_oauth.go index d51544dd7c7..7497c24d619 100644 --- a/pkg/login/social/connectors/gitlab_oauth.go +++ b/pkg/login/social/connectors/gitlab_oauth.go @@ -87,7 +87,7 @@ func (s *SocialGitlab) Validate(ctx context.Context, newSettings ssoModels.SSOSe } func (s *SocialGitlab) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GitlabProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } diff --git a/pkg/login/social/connectors/google_oauth.go b/pkg/login/social/connectors/google_oauth.go index 3044548948b..113b4c0ef5a 100644 --- a/pkg/login/social/connectors/google_oauth.go +++ b/pkg/login/social/connectors/google_oauth.go @@ -87,7 +87,7 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe } func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GoogleProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } diff --git a/pkg/login/social/connectors/grafana_com_oauth.go b/pkg/login/social/connectors/grafana_com_oauth.go index 3f016b1f01c..84ea01632d1 100644 --- a/pkg/login/social/connectors/grafana_com_oauth.go +++ b/pkg/login/social/connectors/grafana_com_oauth.go @@ -39,15 +39,22 @@ type OrgRecord struct { } func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGrafanaCom { + s := newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg) + // Override necessary settings info.AuthUrl = cfg.GrafanaComURL + "/oauth2/authorize" info.TokenUrl = cfg.GrafanaComURL + "/api/oauth2/token" info.AuthStyle = "inheader" + allowedOrganizations, err := util.SplitStringWithError(info.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err) + } + provider := &SocialGrafanaCom{ - SocialBase: newSocialBase(social.GrafanaComProviderName, orgRoleMapper, info, features, cfg), + SocialBase: s, url: cfg.GrafanaComURL, - allowedOrganizations: util.SplitString(info.Extra[allowedOrganizationsKey]), + allowedOrganizations: allowedOrganizations, } if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) { @@ -80,11 +87,16 @@ func (s *SocialGrafanaCom) Validate(ctx context.Context, newSettings ssoModels.S } func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.GrafanaComProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } + allowedOrganizations, err := util.SplitStringWithError(newInfo.Extra[allowedOrganizationsKey]) + if err != nil { + s.log.Error("Invalid auth configuration setting", "config", allowedOrganizationsKey, "provider", social.GrafanaComProviderName, "error", err) + } + // Override necessary settings newInfo.AuthUrl = s.cfg.GrafanaComURL + "/oauth2/authorize" newInfo.TokenUrl = s.cfg.GrafanaComURL + "/api/oauth2/token" @@ -96,7 +108,7 @@ func (s *SocialGrafanaCom) Reload(ctx context.Context, settings ssoModels.SSOSet s.updateInfo(ctx, social.GrafanaComProviderName, newInfo) s.url = s.cfg.GrafanaComURL - s.allowedOrganizations = util.SplitString(newInfo.Extra[allowedOrganizationsKey]) + s.allowedOrganizations = allowedOrganizations return nil } diff --git a/pkg/login/social/connectors/okta_oauth.go b/pkg/login/social/connectors/okta_oauth.go index b126c2acd1d..ffa3a32a350 100644 --- a/pkg/login/social/connectors/okta_oauth.go +++ b/pkg/login/social/connectors/okta_oauth.go @@ -84,7 +84,7 @@ func (s *SocialOkta) Validate(ctx context.Context, newSettings ssoModels.SSOSett } func (s *SocialOkta) Reload(ctx context.Context, settings ssoModels.SSOSettings) error { - newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings) + newInfo, err := CreateOAuthInfoFromKeyValuesWithLogging(s.log, social.OktaProviderName, settings.Settings) if err != nil { return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err) } diff --git a/pkg/login/social/socialimpl/service.go b/pkg/login/social/socialimpl/service.go index ddfb9bf7016..65a9a5573cc 100644 --- a/pkg/login/social/socialimpl/service.go +++ b/pkg/login/social/socialimpl/service.go @@ -65,7 +65,7 @@ func ProvideService(cfg *setting.Cfg, continue } - info, err := connectors.CreateOAuthInfoFromKeyValues(ssoSetting.Settings) + info, err := connectors.CreateOAuthInfoFromKeyValuesWithLogging(ss.log, ssoSetting.Provider, ssoSetting.Settings) if err != nil { ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", ssoSetting.Provider) continue @@ -85,7 +85,7 @@ func ProvideService(cfg *setting.Cfg, settingsKVs := convertIniSectionToMap(sec) - info, err := connectors.CreateOAuthInfoFromKeyValues(settingsKVs) + info, err := connectors.CreateOAuthInfoFromKeyValuesWithLogging(ss.log, name, settingsKVs) if err != nil { ss.log.Error("Failed to create OAuthInfo for provider", "error", err, "provider", name) continue diff --git a/pkg/util/strings.go b/pkg/util/strings.go index f3a2d35540f..b3bbed21cf2 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -33,9 +33,18 @@ func stringsFallback(vals ...string) string { // SplitString splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces. // It supports quoted strings with spaces, e.g. "foo bar", "baz". +// It will return an empty list if it fails to parse the string. func SplitString(str string) []string { + result, _ := SplitStringWithError(str) + return result +} + +// SplitStringWithError splits a string and returns a list of strings. It supports JSON list syntax and strings separated by commas or spaces. +// It supports quoted strings with spaces, e.g. "foo bar", "baz". +// It returns an error if it cannot parse the string. +func SplitStringWithError(str string) ([]string, error) { if len(str) == 0 { - return []string{} + return []string{}, nil } // JSON list syntax support @@ -43,9 +52,9 @@ func SplitString(str string) []string { var res []string err := json.Unmarshal([]byte(str), &res) if err != nil { - return []string{} + return []string{}, fmt.Errorf("incorrect format: %s", str) } - return res + return res, nil } matches := stringListItemMatcher.FindAllString(str, -1) @@ -55,7 +64,7 @@ func SplitString(str string) []string { result[i] = strings.Trim(match, "\"") } - return result + return result, nil } // GetAgeString returns a string representing certain time from years to minutes. diff --git a/public/app/features/auth-config/utils/data.ts b/public/app/features/auth-config/utils/data.ts index c0beae9ec27..00a3dd84453 100644 --- a/public/app/features/auth-config/utils/data.ts +++ b/public/app/features/auth-config/utils/data.ts @@ -56,7 +56,10 @@ const strToValue = (val: string | string[]): SelectableValue[] => { } // Stored as JSON Array if (val.startsWith('[') && val.endsWith(']')) { - return JSON.parse(val).map((v: string) => ({ label: v, value: v })); + // Fallback to parsing it like a non-json string if it is not valid json, instead of crashing. + try { + return JSON.parse(val).map((v: string) => ({ label: v, value: v })); + } catch {} } return val.split(/[\s,]/).map((s) => ({ label: s, value: s })); From 02118cc6aad41160743d5490bedcc3eacb366aed Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 13 Feb 2025 17:44:13 +0000 Subject: [PATCH 66/78] Chore: Automerge i18n PRs (#99555) * add enable automerge step and update CODEOWNERS * add approver steps * move automerge step to pr approver token * get vault secrets * update workflow permissions * remove local --- .github/CODEOWNERS | 5 ++ .github/workflows/i18n-crowdin-download.yml | 57 +++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a349655800b..f10a2d3b391 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -381,6 +381,11 @@ /crowdin.yml @grafana/grafana-frontend-platform /public/locales/ @grafana/grafana-frontend-platform +/public/locales/de-DE @grafanabot +/public/locales/es-ES @grafanabot +/public/locales/fr-FR @grafanabot +/public/locales/pt-BR @grafanabot +/public/locales/zh-Hans @grafanabot /public/app/core/internationalization/ @grafana/grafana-frontend-platform /e2e/ @grafana/grafana-frontend-platform /e2e/cloud-plugins-suite/ @grafana/partner-datasources diff --git a/.github/workflows/i18n-crowdin-download.yml b/.github/workflows/i18n-crowdin-download.yml index cba14abe43b..e0c3c50b9bb 100644 --- a/.github/workflows/i18n-crowdin-download.yml +++ b/.github/workflows/i18n-crowdin-download.yml @@ -3,7 +3,7 @@ name: Crowdin Download Action on: workflow_dispatch: schedule: - - cron: "0 * * * *" + - cron: "0 0 * * *" jobs: download-sources-from-crowdin: @@ -12,6 +12,7 @@ jobs: permissions: contents: write # needed to commit changes into the PR pull-requests: write # needed to update PR description, labels, etc + id-token: write # needed to get vault secrets steps: - name: Generate token @@ -41,17 +42,11 @@ jobs: pull_request_body: | :robot: Automatic download of translations from Crowdin. - Steps for merging: - 1. A quick sanity check of the changes and approve. Things to look out for: - - No changes in the English file. The source of truth is in the main branch, NOT in Crowdin. - - Translations maybe be removed if the English phrase was removed, but there should not be many of these - - Anything else that looks 'funky'. Ask if you're not sure. - 2. Approve & (Auto-)merge. :tada: + This runs once per day and will merge automatically if all the required checks pass. - If there's a conflict, close the pull request and **delete the branch**. A GH action will recreate the pull request. - Remember, the longer this pull request is open, the more likely it is that it'll get conflicts. + If there's a conflict, close the pull request and **delete the branch**. + You can then either wait for the schedule to trigger a new PR, or rerun the action manually. pull_request_labels: 'area/frontend, area/internationalization, no-changelog, no-backport' - pull_request_reviewers: 'grafana-frontend-platform' pull_request_base_branch_name: 'main' base_url: 'https://grafana.api.crowdin.com' config: 'crowdin.yml' @@ -119,3 +114,45 @@ jobs: with: pr: ${{ steps.crowdin-download.outputs.pull_request_number }} token: ${{ steps.generate_token.outputs.token }} + + - name: Get vault secrets + id: vault-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@main + with: + # Secrets placed in ci/repo/grafana/grafana/grafana-pr-approver + repo_secrets: | + GRAFANA_PR_APPROVER_APP_ID=grafana-pr-approver:app-id + GRAFANA_PR_APPROVER_APP_PEM=grafana-pr-approver:private-key + + - name: Generate approver token + if: steps.crowdin-download.outputs.pull_request_url + id: generate_approver_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ env.GRAFANA_PR_APPROVER_APP_ID }} + private_key: ${{ env.GRAFANA_PR_APPROVER_APP_PEM }} + + - name: Approve and automerge PR + if: steps.crowdin-download.outputs.pull_request_url + shell: bash + # Only approve if: + # - the PR does not modify files other than json files under the public/locales/ directory + # - the PR does not modify the en-US locale + run: | + filesChanged=$(gh pr diff --name-only ${{ steps.crowdin-download.outputs.pull_request_url }}) + + if [[ $(echo $filesChanged | grep -v 'public/locales/[a-zA-Z\-]*/grafana.json' | wc -l) -ne 0 ]]; then + echo "Non-i18n changes detected, not approving" + exit 1 + fi + + if [[ $(echo $filesChanged | grep "public/locales/en-US" | wc -l) -ne 0 ]]; then + echo "public/locales/en-US changes detected, not approving" + exit 1 + fi + + echo "Approving and enabling automerge" + gh pr review ${{ steps.crowdin-download.outputs.pull_request_url }} --approve + gh pr merge --auto --squash ${{ steps.crowdin-download.outputs.pull_request_url }} + env: + GITHUB_TOKEN: ${{ steps.generate_approver_token.outputs.token }} From 5aeaa18ac2d4c8866b774b8b9bd175d216c0e065 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:46:29 -0600 Subject: [PATCH 67/78] Canvas: One click links and actions (#99616) Co-authored-by: Leon Sorokin --- .../VizTooltip/VizTooltipFooter.tsx | 95 +++++++---- public/app/features/actions/utils.ts | 1 + public/app/features/canvas/element.ts | 3 +- public/app/features/canvas/elements/cloud.tsx | 3 +- .../features/canvas/elements/droneFront.tsx | 3 +- .../features/canvas/elements/droneSide.tsx | 3 +- .../app/features/canvas/elements/droneTop.tsx | 3 +- .../app/features/canvas/elements/ellipse.tsx | 3 +- public/app/features/canvas/elements/icon.tsx | 3 +- .../features/canvas/elements/metricValue.tsx | 9 +- .../canvas/elements/parallelogram.tsx | 3 +- .../features/canvas/elements/rectangle.tsx | 3 +- .../canvas/elements/server/server.tsx | 3 +- public/app/features/canvas/elements/text.tsx | 3 +- .../app/features/canvas/elements/triangle.tsx | 3 +- .../features/canvas/elements/windTurbine.tsx | 3 +- .../app/features/canvas/runtime/element.tsx | 148 ++++++++++++------ public/app/features/canvas/runtime/scene.tsx | 2 + .../app/plugins/panel/canvas/CanvasPanel.tsx | 5 + .../panel/canvas/components/CanvasTooltip.tsx | 1 - .../canvas/editor/element/elementEditor.tsx | 35 +---- .../plugins/panel/canvas/migrations.test.ts | 14 +- public/app/plugins/panel/canvas/migrations.ts | 23 ++- public/app/plugins/panel/canvas/module.tsx | 1 - public/locales/en-US/grafana.json | 1 + public/locales/pseudo-LOCALE/grafana.json | 1 + 26 files changed, 218 insertions(+), 157 deletions(-) diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index c3c4a34eb64..5b79c42711f 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -1,11 +1,13 @@ import { css } from '@emotion/css'; +import { useMemo } from 'react'; -import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data'; import { Button, DataLinkButton, Icon, Stack } from '..'; import { useStyles2 } from '../../themes'; import { Trans } from '../../utils/i18n'; import { ActionButton } from '../Actions/ActionButton'; +import { ResponsiveProp } from '../Layout/utils/responsiveness'; interface VizTooltipFooterProps { dataLinks: Array>; @@ -15,50 +17,75 @@ interface VizTooltipFooterProps { export const ADD_ANNOTATION_ID = 'add-annotation-button'; -const renderDataLinks = (dataLinks: LinkModel[], styles: ReturnType) => { - const oneClickLink = dataLinks.find((link) => link.oneClick === true); +type RenderOneClickTrans = (title: string) => React.ReactNode; +type RenderItem = ( + item: T, + idx: number, + styles: ReturnType +) => React.ReactNode; + +function makeRenderLinksOrActions( + renderOneClickTrans: RenderOneClickTrans, + renderItem: RenderItem, + itemGap?: ResponsiveProp +) { + const renderLinksOrActions = (items: T[], styles: ReturnType) => { + if (items.length === 0) { + return; + } + + const oneClickItem = items.find((item) => item.oneClick === true); + + if (oneClickItem != null) { + return ( +
      + + + + {renderOneClickTrans(oneClickItem.title)} + + +
      + ); + } - if (oneClickLink != null) { return ( - - - - - Click to open {{ linkTitle: oneClickLink.title }} - - - +
      + + {items.map((item, i) => renderItem(item, i, styles))} + +
      ); - } + }; - return ( - - {dataLinks.map((link, i) => ( - - ))} - - ); -}; + return renderLinksOrActions; +} -const renderActions = (actions: ActionModel[]) => { - return ( - - {actions.map((action, i) => ( - - ))} - - ); -}; +const renderDataLinks = makeRenderLinksOrActions( + (title) => ( + Click to open {{ linkTitle: title }} + ), + (item, i, styles) => ( + + ), + 0.5 +); + +const renderActions = makeRenderLinksOrActions( + (title) => Click to {{ actionTitle: title }}, + (item, i, styles) => +); export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => { const styles = useStyles2(getStyles); - const hasOneClickLink = dataLinks.some((link) => link.oneClick === true); + const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]); + const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]); return (
      - {dataLinks.length > 0 &&
      {renderDataLinks(dataLinks, styles)}
      } - {!hasOneClickLink && actions.length > 0 &&
      {renderActions(actions)}
      } - {!hasOneClickLink && annotate != null && ( + {!hasOneClickAction && renderDataLinks(dataLinks, styles)} + {!hasOneClickLink && renderActions(actions, styles)} + {!hasOneClickLink && !hasOneClickAction && annotate != null && (
      )} diff --git a/public/app/features/search/service/unified.test.ts b/public/app/features/search/service/unified.test.ts index e62a126f38f..8841418cd5a 100644 --- a/public/app/features/search/service/unified.test.ts +++ b/public/app/features/search/service/unified.test.ts @@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => { .mockResolvedValueOnce(mockResults) .mockResolvedValueOnce(mockFolders); - const consoleWarn = jest.fn(); - jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn); - const query: SearchQuery = { query: 'test', limit: 50, @@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => { const response = await searcher.search(query); - expect(response.view.length).toBe(1); - expect(response.view.get(0).title).toBe('DB 2'); + expect(response.view.length).toBe(2); + expect(response.view.get(0).title).toBe('DB 1'); + expect(response.view.get(0).folder).toBe('sharedwithme'); + expect(response.view.get(1).title).toBe('DB 2'); const df = response.view.dataFrame; const locationInfo = df.meta?.custom?.locationInfo; expect(locationInfo).toBeDefined(); expect(locationInfo?.folder2.name).toBe('Folder 2'); - expect(consoleWarn).toHaveBeenCalled(); expect(mockSearcher.search).toHaveBeenCalledTimes(3); }); diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 9d942f668d3..8e270aba37b 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher { if (!hasMissing) { return rsp; } - // we still have results here with folders we can't find - // filter the results since we probably don't have access to that folder + const locationInfo = await this.locationInfo; - const hits = rsp.hits.filter((hit) => { - if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) { - return true; + const hits = rsp.hits.map((hit) => { + if (hit.folder === undefined) { + return { ...hit, location: 'general', folder: 'general' }; } - console.warn('Dropping search hit with missing folder', hit); - return false; + + // this means user has permission to see this dashboard, but not the folder contents + if (locationInfo[hit.folder] === undefined) { + return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' }; + } + + return hit; }); const totalHits = rsp.totalHits - (rsp.hits.length - hits.length); return { ...rsp, hits, totalHits }; @@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise> { name: 'Dashboards', url: '/dashboards', }, // share location info with everyone + sharedwithme: { + kind: 'sharedwithme', + name: 'Shared with me', + url: '', + }, }; for (const hit of rsp.hits) { locationInfo[hit.name] = { diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index 6aeeaefba92..85c0168e480 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName { return isOpen ? 'folder-open' : 'folder'; } + if (kind === 'sharedwithme') { + return 'users-alt'; + } + return 'question-circle'; } From 1c2f4e35bf1a12a693df3c8c4052ae40b0e55fef Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Thu, 13 Feb 2025 15:44:15 -0700 Subject: [PATCH 76/78] Frontend tests: comment out flaky test (#100685) --- e2e/various-suite/solo-route.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 415257ead7c..c7f90f8cd36 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -22,7 +22,7 @@ describe('Solo Route', () => { cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo repeated panel in scenes', () => { + /*it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' @@ -30,7 +30,7 @@ describe('Solo Route', () => { e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); - }); + });*/ it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG From 7f20495289869d01cb5f5c274af7625e8fa267ab Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Fri, 14 Feb 2025 02:30:34 +0200 Subject: [PATCH 77/78] I18n: Download translations from Crowdin (#100689) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 422 +++++++++++++++++++++++----- public/locales/es-ES/grafana.json | 422 +++++++++++++++++++++++----- public/locales/fr-FR/grafana.json | 422 +++++++++++++++++++++++----- public/locales/pt-BR/grafana.json | 422 +++++++++++++++++++++++----- public/locales/zh-Hans/grafana.json | 422 +++++++++++++++++++++++----- 5 files changed, 1770 insertions(+), 340 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 46fe1314938..fed79005cc6 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Liste in Grafana Alerting", "subtitle": "Benachrichtigungsregeln im Zusammenhang mit diesem Dashboard" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Visualisierungen hinzufügen, die mit anderen Dashboards geteilt werden.", "add-library-panel-button": "Bibliotheksfenster hinzufügen", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Dashboard importieren", "import-dashboard-button": "Dashboard importieren" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Daten", "error-tab": "Fehler", @@ -926,19 +1040,130 @@ "rows": "Gesamtanzahl an Zeilen", "table-title": "Statistiken" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Hinzufügen", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Warnregeln", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Als Favorit markieren", + "more-save-options": "", "open-original": "Original-Dashboard öffnen", "playlist-next": "Zum nächsten Dashboard", "playlist-previous": "Zum vorherigen Dashboard", "playlist-stop": "Wiedergabeliste stoppen", + "public-dashboard": "", "refresh": "Dashboard aktualisieren", "save": "Dashboard speichern", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Dashboard-Einstellungen", - "share": "Dashboard oder Panel teilen", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Teilen", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Markierung als Favorit entfernen" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON ungültig", "tags-expected-array": "Array der jeweiligen Tags", "tags-expected-strings": "String-Array der jeweiligen Tags" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Abfrageverlauf schließen", "datasource-a-z": "Datenquelle A-Z", "datasource-z-a": "Datenquelle Z-A", + "library-history-dropdown": "", "newest-first": "Neueste zuerst", "oldest-first": "Älteste zuerst", "query-history": "Abfrageverlauf", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Dashboard", "datasource": "Datenquelle", @@ -2031,6 +2282,15 @@ "title": "Warum mit Grafana hosten?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Neue Verbindung hinzufügen" @@ -2384,7 +2644,8 @@ "list-label": "Navigation", "open": "", "undock": "Menü abdocken" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Rolle" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Abfragezeile einklappen", @@ -2760,7 +3029,6 @@ "expand-row": "Suchzeile erweitern", "hide-response": "", "remove-query": "Abfrage entfernen", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Textbearbeitungsmodus umschalten" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Standard-Dashboard", "locale-label": "Sprache", "locale-placeholder": "Sprache wählen", + "theme-description": "", "theme-label": "UI-Design", "week-start-label": "Wochenbeginn" }, @@ -3216,6 +3489,13 @@ "url-column-header": "Snapshot url", "view-button": "Anzeigen" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Wird geladen ...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index f324380f834..02db11b3d08 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Lista en Alertas de Grafana", "subtitle": "Reglas de alerta relacionadas con este tablero" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Añadir las visualizaciones que se comparten con otros tableros.", "add-library-panel-button": "Añadir panel de biblioteca", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Importar un tablero", "import-dashboard-button": "Importar panel de control" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Datos", "error-tab": "Error", @@ -926,19 +1040,130 @@ "rows": "Número total de filas", "table-title": "Estadísticas" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Añadir", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Reglas de alerta", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Marcar como favorito", + "more-save-options": "", "open-original": "Abrir el panel de control original", "playlist-next": "Ir al siguiente panel de control", "playlist-previous": "Ir al panel de control anterior", "playlist-stop": "Detener la lista de reproducción", + "public-dashboard": "", "refresh": "Actualizar panel de control", "save": "Guardar panel de control", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Ajustes del panel de control", - "share": "Compartir panel o panel de control", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Compartir", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Deshacer marca como favorito" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON no válido", "tags-expected-array": "etiquetas: matriz prevista", "tags-expected-strings": "etiquetas: matriz de cadenas prevista" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Cerrar el historial de consultas", "datasource-a-z": "Fuente de datos A-Z", "datasource-z-a": "Fuente de datos Z-A", + "library-history-dropdown": "", "newest-first": "El más reciente primero", "oldest-first": "El más antiguo primero", "query-history": "Historial de consultas", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Panel de control", "datasource": "Fuente de datos", @@ -2031,6 +2282,15 @@ "title": "¿Por qué alojar con Grafana?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Añadir nueva conexión" @@ -2384,7 +2644,8 @@ "list-label": "Navegación", "open": "", "undock": "Desanclar el menú" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Rol" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Contraer la fila de la consulta", @@ -2760,7 +3029,6 @@ "expand-row": "Expandir la fila de la consulta", "hide-response": "", "remove-query": "Eliminar consulta", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Alternar el modo de edición de texto" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Panel de control por defecto", "locale-label": "Idioma", "locale-placeholder": "Cambiar idioma", + "theme-description": "", "theme-label": "Tema de interfaz de usuario", "week-start-label": "Inicio de la semana" }, @@ -3216,6 +3489,13 @@ "url-column-header": "URL de la instantánea", "view-button": "Vista" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Cargando...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 2ea9883ffe1..33cfe2d4b40 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Liste dans Alertes Grafana", "subtitle": "Règles d'alerte liées à ce tableau de bord" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Ajoutez des visualisations partagées avec d'autres tableaux de bord.", "add-library-panel-button": "Ajouter un panneau Bibliothèque", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Importer un tableau de bord", "import-dashboard-button": "Importer un tableau de bord" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Données", "error-tab": "Erreur", @@ -926,19 +1040,130 @@ "rows": "Nombre total de lignes", "table-title": "Statistiques" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Ajouter", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Règles d'alerte", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Marquer comme favori", + "more-save-options": "", "open-original": "Ouvrir le tableau de bord d'origine", "playlist-next": "Accéder au tableau de bord suivant", "playlist-previous": "Accéder au tableau de bord précédent", "playlist-stop": "Arrêter la liste de lecture", + "public-dashboard": "", "refresh": "Actualiser le tableau de bord", "save": "Enregistrer le tableau de bord", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Paramètres du tableau de bord", - "share": "Partager le tableau de bord ou le panneau", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Partager", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Supprimer des favoris" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON non valide", "tags-expected-array": "étiquettes attendues tableau", "tags-expected-strings": "étiquettes attendues tableau de chaînes" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Fermer l'historique des requêtes", "datasource-a-z": "Source de données A-Z", "datasource-z-a": "Source de données Z-A", + "library-history-dropdown": "", "newest-first": "Plus récent en premier", "oldest-first": "Plus ancien en premier", "query-history": "Historique des requêtes", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Tableau de bord", "datasource": "Source de données", @@ -2031,6 +2282,15 @@ "title": "Pourquoi héberger avec Grafana ?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Ajouter une nouvelle connexion" @@ -2384,7 +2644,8 @@ "list-label": "Navigation", "open": "", "undock": "Ancrer le menu" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Rôle" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Réduire la ligne de requête", @@ -2760,7 +3029,6 @@ "expand-row": "Développer la ligne de requête", "hide-response": "", "remove-query": "Supprimer la requête", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Activer/désactiver le mode édition de texte" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Tableau de bord par défaut", "locale-label": "Langue", "locale-placeholder": "Choisir une langue", + "theme-description": "", "theme-label": "Thème de l'interface utilisateur", "week-start-label": "Début de la semaine" }, @@ -3216,6 +3489,13 @@ "url-column-header": "URL de l'instantané", "view-button": "Afficher" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Chargement en cours...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 5e0b7cdc2d4..4e648b574bc 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Lista no alerta do Grafana", "subtitle": "Regras de alerta relacionadas a este painel de controle" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Adicione visualizações que são compartilhadas com outros painéis de controle.", "add-library-panel-button": "Adicionar painel de biblioteca", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Importar um painel de controle", "import-dashboard-button": "Importar painel de controle" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Dados", "error-tab": "Erro", @@ -926,19 +1040,130 @@ "rows": "Número total de linhas", "table-title": "Estatísticas" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Adicionar", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Regras de alerta", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Marcar como favorito", + "more-save-options": "", "open-original": "Abrir painel de controle original", "playlist-next": "Ir para o próximo painel de controle", "playlist-previous": "Ir para o painel de controle anterior", "playlist-stop": "Parar lista de reprodução", + "public-dashboard": "", "refresh": "Atualizar painel de controle", "save": "Salvar painel de controle", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Configurações do painel de controle", - "share": "Compartilhar painel de controle", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Compartilhar", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Desmarcar como favorito" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON inválido", "tags-expected-array": "matriz de tags esperada", "tags-expected-strings": "matriz de strings de tags esperada" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Fechar histórico de consultas", "datasource-a-z": "Fonte de dados A-Z", "datasource-z-a": "Fonte de dados Z-A", + "library-history-dropdown": "", "newest-first": "Mais recentes primeiro", "oldest-first": "Mais antigos primeiro", "query-history": "Histórico de consultas", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Painel de controle", "datasource": "Fonte de dados", @@ -2031,6 +2282,15 @@ "title": "Por que hospedar com o Grafana?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Adicionar nova conexão" @@ -2384,7 +2644,8 @@ "list-label": "Navegação", "open": "", "undock": "Desacoplar menu" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Função" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Recolher linha de consulta", @@ -2760,7 +3029,6 @@ "expand-row": "Expandir linha de consulta", "hide-response": "", "remove-query": "Remover consulta", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Alternar modo de edição de texto" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Painel de controle padrão", "locale-label": "Idioma", "locale-placeholder": "Escolher idioma", + "theme-description": "", "theme-label": "Tema da interface", "week-start-label": "Início da semana" }, @@ -3216,6 +3489,13 @@ "url-column-header": "URL da captura", "view-button": "Visualizar" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Carregando...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index c5cba66ace2..ba17087b1e7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -281,6 +293,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -297,6 +310,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -480,11 +500,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -681,7 +713,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -854,6 +889,82 @@ "redirect-link": "Grafana Alerting 中的列表", "subtitle": "与此仪表板相关的警报规则" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "添加与其他仪表板共享的可视化。", "add-library-panel-button": "添加库面板", @@ -865,6 +976,9 @@ "import-a-dashboard-header": "导入仪表板", "import-dashboard-button": "导入仪表板" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "数据", "error-tab": "错误", @@ -917,19 +1031,130 @@ "rows": "总行数", "table-title": "统计信息" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "添加", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "警报规则", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "标记为收藏", + "more-save-options": "", "open-original": "打开原始仪表板", "playlist-next": "前往下一个仪表板", "playlist-previous": "前往上一个仪表板", "playlist-stop": "停止播放列表", + "public-dashboard": "", "refresh": "刷新仪表板", "save": "保存仪表板", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "仪表板设置", - "share": "分享仪表板或面板", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "分享", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "取消标记为收藏" }, "validation": { @@ -937,6 +1162,14 @@ "invalid-json": "无有效的 JSON", "tags-expected-array": "标签预期数组", "tags-expected-strings": "标签预期字符串数组" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1039,6 +1272,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1116,40 +1355,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "关闭查询历史记录", "datasource-a-z": "数据源 A-Z", "datasource-z-a": "数据源 Z-A", + "library-history-dropdown": "", "newest-first": "最新在前", "oldest-first": "最早在前", "query-history": "查询历史记录", @@ -1296,10 +1506,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1348,6 +1557,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1364,11 +1591,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1449,7 +1686,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1770,7 +2012,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1782,6 +2025,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1871,6 +2121,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1931,9 +2182,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1976,6 +2226,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "仪表板", "datasource": "数据源", @@ -2021,6 +2272,15 @@ "title": "为什么使用 Grafana 托管?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "添加新连接" @@ -2374,7 +2634,8 @@ "list-label": "导航", "open": "", "undock": "取消停靠菜单" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2506,19 +2767,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2732,14 +3009,6 @@ "role-label": "角色" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "折叠查询行", @@ -2749,7 +3018,6 @@ "expand-row": "展开查询行", "hide-response": "", "remove-query": "删除查询", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "切换文本编辑模式" }, @@ -2848,6 +3116,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3131,6 +3403,7 @@ "home-dashboard-placeholder": "默认仪表板", "locale-label": "语言", "locale-placeholder": "选择语言", + "theme-description": "", "theme-label": "UI 主题", "week-start-label": "每周开始日" }, @@ -3202,6 +3475,13 @@ "url-column-header": "快照网址", "view-button": "查看" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "加载中...", @@ -3295,20 +3575,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, From e2e93bed67707bd977f2a1a7d49f8641cedabd1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Fri, 14 Feb 2025 11:07:34 +0100 Subject: [PATCH 78/78] [Provisioning] Use ProgressRecorder in Export Job (#100703) * Use progress recorder instead * Use recorder for folders * More refactoring * Move things to resources * More situations * Fix more TODOs * Refactor progress to precalculate summary * Do not store results * Fix bug with dashboards error --- .../apis/provisioning/jobs/export/folders.go | 86 +++++----- .../apis/provisioning/jobs/export/job.go | 154 ++---------------- .../provisioning/jobs/export/resources.go | 152 +++++++++++++---- .../apis/provisioning/jobs/export/users.go | 5 +- .../apis/provisioning/jobs/export/worker.go | 60 ++++--- .../apis/provisioning/jobs/progress.go | 111 ++++++------- .../apis/provisioning/resources/tree.go | 3 +- 7 files changed, 268 insertions(+), 303 deletions(-) diff --git a/pkg/registry/apis/provisioning/jobs/export/folders.go b/pkg/registry/apis/provisioning/jobs/export/folders.go index c47e667e130..fea92b25f23 100644 --- a/pkg/registry/apis/provisioning/jobs/export/folders.go +++ b/pkg/registry/apis/provisioning/jobs/export/folders.go @@ -11,22 +11,19 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/storage/unified/parquet" "github.com/grafana/grafana/pkg/storage/unified/resource" ) -var ( - _ resource.BatchResourceWriter = (*folderReader)(nil) -) +var _ resource.BatchResourceWriter = (*folderReader)(nil) type folderReader struct { tree *resources.FolderTree targetRepoName string - summary *provisioning.JobResourceSummary } // Close implements resource.BatchResourceWriter. @@ -44,33 +41,25 @@ func (f *folderReader) Write(ctx context.Context, key *resource.ResourceKey, val item := &unstructured.Unstructured{} err := item.UnmarshalJSON(value) if err != nil { - return err + return fmt.Errorf("unmarshal unstructured to JSON: %w", err) } - err = f.tree.AddUnstructured(item, f.targetRepoName) - if err != nil { - f.summary.Errors = append(f.summary.Errors, err.Error()) - } - return nil + + return f.tree.AddUnstructured(item, f.targetRepoName) } +// FIXME: revise logging in this method func (r *exportJob) loadFolders(ctx context.Context) error { logger := r.logger - status := r.jobStatus - status.Message = "reading folder tree" - r.maybeNotify(ctx) + r.progress.SetMessage("reading folder tree") - summary := r.getSummary(schema.GroupResource{ - Group: folders.GROUP, - Resource: folders.RESOURCE, - }) - - reader := &folderReader{ - tree: resources.NewEmptyFolderTree(), - targetRepoName: r.target.Config().Name, - summary: summary, - } + repoName := r.target.Config().Name if r.legacy != nil { + r.progress.SetMessage("migrate folder tree from legacy") + reader := &folderReader{ + tree: r.folderTree, + targetRepoName: repoName, + } _, err := r.legacy.Migrate(ctx, legacy.MigrateOptions{ Namespace: r.namespace, Resources: []schema.GroupResource{{ @@ -83,6 +72,8 @@ func (r *exportJob) loadFolders(ctx context.Context) error { return fmt.Errorf("unable to read folders from legacy storage %w", err) } } else { + // TODO: should this be logging or message or both? + r.progress.SetMessage("read folder tree from unified storage") client := r.client.Resource(schema.GroupVersionResource{ Group: folders.GROUP, Version: folders.VERSION, @@ -96,46 +87,63 @@ func (r *exportJob) loadFolders(ctx context.Context) error { if rawList.GetContinue() != "" { return fmt.Errorf("unable to list all folders in one request: %s", rawList.GetContinue()) } + for _, item := range rawList.Items { - err = reader.tree.AddUnstructured(&item, reader.targetRepoName) + err = r.folderTree.AddUnstructured(&item, repoName) if err != nil { - summary.Errors = append(summary.Errors, err.Error()) + r.progress.Record(ctx, jobs.JobResourceResult{ + Name: item.GetName(), + Resource: folders.RESOURCE, + Group: folders.GROUP, + Error: err, + }) } } } - // first create folders - // NOTE: this is required so that empty folders exist when finished - status.Message = "writing folders" - err := reader.tree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error { + // create folders first is required so that empty folders exist when finished + r.progress.SetMessage("write folders") + + err := r.folderTree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error { p := folder.Path + "/" if r.prefix != "" { p = r.prefix + "/" + p } logger := logger.With("path", p) + result := jobs.JobResourceResult{ + Name: folder.ID, + Resource: folders.RESOURCE, + Group: folders.GROUP, + Path: p, + } + _, err := r.target.Read(ctx, p, r.ref) if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { - logger.Error("failed to check if folder exists before writing", "error", err) - return fmt.Errorf("failed to check if folder exists before writing: %w", err) + result.Error = fmt.Errorf("failed to check if folder exists before writing: %w", err) + return result.Error } else if err == nil { logger.Info("folder already exists") - summary.Noop++ + result.Action = repository.FileActionIgnored + r.progress.Record(ctx, result) return nil } + result.Action = repository.FileActionCreated + msg := fmt.Sprintf("export folder %s", p) // Create with an empty body will make a folder (or .keep file if unsupported) - if err := r.target.Create(ctx, p, r.ref, nil, "export folder `"+p+"`"); err != nil { - logger.Error("failed to write a folder in repository", "error", err) - return fmt.Errorf("failed to write folder in repo: %w", err) + if err := r.target.Create(ctx, p, r.ref, nil, msg); err != nil { + result.Error = fmt.Errorf("failed to write folder in repo: %w", err) + r.progress.Record(ctx, result) + return result.Error } - summary.Create++ - logger.Debug("successfully exported folder") + + r.progress.Record(ctx, result) return nil }) if err != nil { return fmt.Errorf("failed to write folders: %w", err) } - r.foldersTree = reader.tree + return nil } diff --git a/pkg/registry/apis/provisioning/jobs/export/job.go b/pkg/registry/apis/provisioning/jobs/export/job.go index abe1e2cad54..b36236e1690 100644 --- a/pkg/registry/apis/provisioning/jobs/export/job.go +++ b/pkg/registry/apis/provisioning/jobs/export/job.go @@ -2,17 +2,10 @@ package export import ( "context" - "encoding/json" - "fmt" - "time" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/utils" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" @@ -28,165 +21,41 @@ type exportJob struct { legacy legacy.LegacyMigrator namespace string - progress jobs.ProgressFn - progressInterval time.Duration - progressLast time.Time - foldersTree *resources.FolderTree - userInfo map[string]repository.CommitSignature + progress *jobs.JobProgressRecorder + + userInfo map[string]repository.CommitSignature + folderTree *resources.FolderTree prefix string // from options (now clean+safe) ref string // from options (only git) keepIdentifier bool withHistory bool - - jobStatus *provisioning.JobStatus - summary map[string]*provisioning.JobResourceSummary } func newExportJob(ctx context.Context, target repository.Repository, options provisioning.ExportJobOptions, client *resources.DynamicClient, - progress jobs.ProgressFn, + progress *jobs.JobProgressRecorder, ) *exportJob { prefix := options.Prefix if prefix != "" { prefix = safepath.Clean(prefix) } return &exportJob{ - namespace: target.Config().Namespace, - target: target, - client: client, - logger: logging.FromContext(ctx), - progress: progress, - progressLast: time.Now(), - progressInterval: time.Second * 5, - + namespace: target.Config().Namespace, + target: target, + client: client, + logger: logging.FromContext(ctx), + progress: progress, prefix: prefix, ref: options.Branch, keepIdentifier: options.Identifier, withHistory: options.History, - - jobStatus: &provisioning.JobStatus{ - State: provisioning.JobStateWorking, - }, - summary: make(map[string]*provisioning.JobResourceSummary), + folderTree: resources.NewEmptyFolderTree(), } } -// Send progress messages to any listeners -func (r *exportJob) maybeNotify(ctx context.Context) { - if time.Since(r.progressLast) > r.progressInterval { - r.progressLast = time.Now() - err := r.progress(ctx, *r.jobStatus) - if err != nil { - r.logger.Warn("unable to send progress", "err", err) - } - } -} - -// Register summary information for a group/resource -func (r *exportJob) getSummary(gr schema.GroupResource) *provisioning.JobResourceSummary { - summary, ok := r.summary[gr.String()] - if !ok { - summary = &provisioning.JobResourceSummary{ - Group: gr.Group, - Resource: gr.Resource, - } - r.summary[gr.String()] = summary - r.jobStatus.Summary = append(r.jobStatus.Summary, summary) - } - return summary -} - -func (r *exportJob) add(ctx context.Context, summary *provisioning.JobResourceSummary, obj *unstructured.Unstructured) error { - if err := ctx.Err(); err != nil { - return err - } - r.maybeNotify(ctx) - - item, err := utils.MetaAccessor(obj) - if err != nil { - return err - } - - // Message from annotations - commitMessage := item.GetMessage() - if commitMessage == "" { - g := item.GetGeneration() - if g > 0 { - commitMessage = fmt.Sprintf("Generation: %d", g) - } else { - commitMessage = "exported from grafana" - } - } - - name := item.GetName() - repoName := item.GetRepositoryName() - if repoName == r.target.Config().GetName() { - r.logger.Info("skip dashboard since it is already in repository", "dashboard", name) - return nil - } - - title := item.FindTitle("") - if title == "" { - title = name - } - folder := item.GetFolder() - - // Add the author in context (if available) - ctx = r.withAuthorSignature(ctx, item) - - // Get the absolute path of the folder - fid, ok := r.foldersTree.DirPath(folder, "") - if !ok { - fid = resources.Folder{ - Path: "__folder_not_found/" + slugify.Slugify(folder), - } - r.logger.Error("folder of item was not in tree of repository") - } - - // Clear the metadata - delete(obj.Object, "metadata") - - if r.keepIdentifier { - item.SetName(name) // keep the identifier in the metadata - } - - body, err := json.MarshalIndent(obj.Object, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal dashboard %s: %w", name, err) - } - - fileName := slugify.Slugify(title) + ".json" - if fid.Path != "" { - fileName, err = safepath.Join(fid.Path, fileName) - if err != nil { - return fmt.Errorf("error adding file path %s: %w", title, err) - } - } - if r.prefix != "" { - fileName, err = safepath.Join(r.prefix, fileName) - if err != nil { - return fmt.Errorf("error adding path prefix %s: %w", r.prefix, err) - } - } - - // Write the file - err = r.target.Write(ctx, fileName, r.ref, body, commitMessage) - if err != nil { - summary.Error++ - r.logger.Error("failed to write a file in repository", "error", err) - if len(summary.Errors) < 20 { - summary.Errors = append(summary.Errors, fmt.Sprintf("error writing: %s", fileName)) - } - } else { - summary.Write++ - } - - return nil -} - func (r *exportJob) withAuthorSignature(ctx context.Context, item utils.GrafanaMetaAccessor) context.Context { if r.userInfo == nil { return ctx @@ -209,5 +78,6 @@ func (r *exportJob) withAuthorSignature(ctx context.Context, item utils.GrafanaM } else { sig.When = item.GetCreationTimestamp().Time } + return repository.WithAuthorSignature(ctx, sig) } diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index a332b6ece41..9c182ccfd48 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -2,28 +2,29 @@ package export import ( "context" + "encoding/json" "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/apimachinery/utils" dashboards "github.com/grafana/grafana/pkg/apis/dashboard" - provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" "github.com/grafana/grafana/pkg/storage/unified/parquet" "github.com/grafana/grafana/pkg/storage/unified/resource" ) -var ( - _ resource.BatchResourceWriter = (*resourceReader)(nil) -) +var _ resource.BatchResourceWriter = (*resourceReader)(nil) type resourceReader struct { - job *exportJob - summary *provisioning.JobResourceSummary - logger logging.Logger + job *exportJob } // Close implements resource.BatchResourceWriter. @@ -41,16 +42,17 @@ func (f *resourceReader) Write(ctx context.Context, key *resource.ResourceKey, v item := &unstructured.Unstructured{} err := item.UnmarshalJSON(value) if err != nil { - return err + // TODO: should we fail the entire execution? + return fmt.Errorf("failed to unmarshal unstructured: %w", err) } - err = f.job.add(ctx, f.summary, item) - if err != nil { - f.logger.Warn("error adding from legacy", "name", key.Name, "err", err) - f.summary.Errors = append(f.summary.Errors, fmt.Sprintf("%s: %s", key.Name, err.Error())) - if len(f.summary.Errors) > 50 { - return err + + if result := f.job.write(ctx, item); result.Error != nil { + f.job.progress.Record(ctx, result) + if len(f.job.progress.Errors()) > 20 { + return fmt.Errorf("stopping execution due to too many errors") } } + return nil } @@ -62,32 +64,30 @@ func (r *exportJob) loadResources(ctx context.Context) error { }} for _, kind := range kinds { - r.jobStatus.Message = "Exporting " + kind.Resource + "..." + r.progress.SetMessage(fmt.Sprintf("exporting %s resource", kind.Resource)) if r.legacy != nil { + r.progress.SetMessage(fmt.Sprintf("migrate %s resource", kind.Resource)) gr := kind.GroupResource() - reader := &resourceReader{ - summary: r.getSummary(gr), - job: r, - logger: r.logger, - } opts := legacy.MigrateOptions{ Namespace: r.namespace, WithHistory: r.withHistory, Resources: []schema.GroupResource{gr}, - Store: parquet.NewBatchResourceWriterClient(reader), + Store: parquet.NewBatchResourceWriterClient(&resourceReader{job: r}), OnlyCount: true, // first get the count } stats, err := r.legacy.Migrate(ctx, opts) if err != nil { return fmt.Errorf("unable to count legacy items %w", err) } + + // FIXME: explain why we calculate it in this way if len(stats.Summary) > 0 { count := stats.Summary[0].Count history := stats.Summary[0].History if history > count { count = history // the number of items we will process } - reader.summary.Total = count + r.progress.SetTotal(int(count)) } opts.OnlyCount = false // this time actually write @@ -97,6 +97,7 @@ func (r *exportJob) loadResources(ctx context.Context) error { } } + r.progress.SetMessage(fmt.Sprintf("reading %s resource", kind.Resource)) if err := r.loadResourcesFromAPIServer(ctx, kind); err != nil { return fmt.Errorf("error loading %s %w", kind.Resource, err) } @@ -105,11 +106,9 @@ func (r *exportJob) loadResources(ctx context.Context) error { } func (r *exportJob) loadResourcesFromAPIServer(ctx context.Context, kind schema.GroupVersionResource) error { - r.maybeNotify(ctx) client := r.client.Resource(kind) - summary := r.getSummary(kind.GroupResource()) - continueToken := "" + var continueToken string for { list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken}) if err != nil { @@ -117,8 +116,9 @@ func (r *exportJob) loadResourcesFromAPIServer(ctx context.Context, kind schema. } for _, item := range list.Items { - if err = r.add(ctx, summary, &item); err != nil { - return fmt.Errorf("error adding value: %w", err) + r.progress.Record(ctx, r.write(ctx, &item)) + if len(r.progress.Errors()) > 20 { + return fmt.Errorf("stopping execution due to too many errors") } } @@ -130,3 +130,99 @@ func (r *exportJob) loadResourcesFromAPIServer(ctx context.Context, kind schema. return nil } + +func (r *exportJob) write(ctx context.Context, obj *unstructured.Unstructured) jobs.JobResourceResult { + gvk := obj.GroupVersionKind() + result := jobs.JobResourceResult{ + Name: obj.GetName(), + Resource: gvk.Kind, + Group: gvk.Group, + Action: repository.FileActionCreated, + } + + if err := ctx.Err(); err != nil { + result.Error = fmt.Errorf("context error: %w", err) + return result + } + + meta, err := utils.MetaAccessor(obj) + if err != nil { + result.Error = fmt.Errorf("extract meta accessor: %w", err) + return result + } + + // Message from annotations + commitMessage := meta.GetMessage() + if commitMessage == "" { + g := meta.GetGeneration() + if g > 0 { + commitMessage = fmt.Sprintf("Generation: %d", g) + } else { + commitMessage = "exported from grafana" + } + } + + name := meta.GetName() + repoName := meta.GetRepositoryName() + if repoName == r.target.Config().GetName() { + result.Action = repository.FileActionIgnored + return result + } + + title := meta.FindTitle("") + if title == "" { + title = name + } + folder := meta.GetFolder() + + // Add the author in context (if available) + ctx = r.withAuthorSignature(ctx, meta) + + // Get the absolute path of the folder + fid, ok := r.folderTree.DirPath(folder, "") + if !ok { + // FIXME: Shouldn't this fail instead? + fid = resources.Folder{ + Path: "__folder_not_found/" + slugify.Slugify(folder), + } + r.logger.Error("folder of item was not in tree of repository") + } + + result.Path = fid.Path + + // Clear the metadata + delete(obj.Object, "metadata") + + if r.keepIdentifier { + meta.SetName(name) // keep the identifier in the metadata + } + + body, err := json.MarshalIndent(obj.Object, "", " ") + if err != nil { + result.Error = fmt.Errorf("failed to marshal dashboard: %w", err) + return result + } + + fileName := slugify.Slugify(title) + ".json" + if fid.Path != "" { + fileName, err = safepath.Join(fid.Path, fileName) + if err != nil { + result.Error = fmt.Errorf("error adding file path: %w", err) + return result + } + } + if r.prefix != "" { + fileName, err = safepath.Join(r.prefix, fileName) + if err != nil { + result.Error = fmt.Errorf("error adding path prefix: %w", err) + return result + } + } + + err = r.target.Write(ctx, fileName, r.ref, body, commitMessage) + if err != nil { + result.Error = fmt.Errorf("failed to write file: %w", err) + } + + return result +} diff --git a/pkg/registry/apis/provisioning/jobs/export/users.go b/pkg/registry/apis/provisioning/jobs/export/users.go index 32548a172c4..5e717f5404b 100644 --- a/pkg/registry/apis/provisioning/jobs/export/users.go +++ b/pkg/registry/apis/provisioning/jobs/export/users.go @@ -14,10 +14,6 @@ import ( ) func (r *exportJob) loadUsers(ctx context.Context) error { - status := r.jobStatus - status.Message = "reading user info" - r.maybeNotify(ctx) - client := r.client.Resource(schema.GroupVersionResource{ Group: iam.GROUP, Version: iam.VERSION, @@ -36,6 +32,7 @@ func (r *exportJob) loadUsers(ctx context.Context) error { r.userInfo = make(map[string]repository.CommitSignature) for _, item := range rawList.Items { sig := repository.CommitSignature{} + // FIXME: should we improve logging here? sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login") if !ok || err != nil { continue diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go index bc27197b907..4e991136cd0 100644 --- a/pkg/registry/apis/provisioning/jobs/export/worker.go +++ b/pkg/registry/apis/provisioning/jobs/export/worker.go @@ -51,58 +51,69 @@ func (r *ExportWorker) IsSupported(ctx context.Context, job provisioning.Job) bo } // Process will start a job -func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.ProgressFn) (*provisioning.JobStatus, error) { +func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progressFn jobs.ProgressFn) (*provisioning.JobStatus, error) { if repo.Config().Spec.ReadOnly { return &provisioning.JobStatus{ - State: provisioning.JobStateError, - Errors: []string{"Exporting to a read only repository is not supported"}, + State: provisioning.JobStateError, + Message: "Exporting to a read only repository is not supported", }, nil } options := job.Spec.Export if options == nil { return &provisioning.JobStatus{ - State: provisioning.JobStateError, - Errors: []string{"Export job missing export settings"}, + State: provisioning.JobStateError, + Message: "Export job missing export settings", }, nil } - var err error - var buffered *gogit.GoGitRepo + progress := jobs.NewJobProgressRecorder(progressFn) + var ( + err error + buffered *gogit.GoGitRepo + ) + if repo.Config().Spec.GitHub != nil { + progress.SetMessage("clone target") buffered, err = gogit.Clone(ctx, repo.Config(), gogit.GoGitCloneOptions{ Root: r.clonedir, SingleCommitBeforePush: !options.History, }, r.secrets, os.Stdout) if err != nil { return &provisioning.JobStatus{ - State: provisioning.JobStateError, - Errors: []string{"Unable to clone target", err.Error()}, + State: provisioning.JobStateError, + Message: "Unable to clone target", + Errors: []string{err.Error()}, }, nil } // New empty branch (same on main???) if options.Branch != "" { + progress.SetMessage("create empty branch") _, err := buffered.NewEmptyBranch(ctx, options.Branch) if err != nil { return &provisioning.JobStatus{ - State: provisioning.JobStateError, - Errors: []string{"Unable to create empty branch", err.Error()}, + State: provisioning.JobStateError, + Message: "Unable to create empty branch", + Errors: []string{err.Error()}, }, nil } } + repo = buffered // send all writes to the buffered repo options.Branch = "" // :( the branch is now baked into the repo } dynamicClient, _, err := r.clients.New(repo.Config().Namespace) if err != nil { + // TODO: how do we really want to return errors? return nil, fmt.Errorf("error getting client %w", err) } worker := newExportJob(ctx, repo, *options, dynamicClient, progress) if options.History { + progress.SetMessage("load users") err = worker.loadUsers(ctx) if err != nil { return nil, fmt.Errorf("error loading users %w", err) @@ -115,28 +126,27 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, } // Load and write all folders + progress.SetMessage("start folder export") err = worker.loadFolders(ctx) if err != nil { - return worker.jobStatus, err + // TODO: handle better + return progress.Complete(ctx, err), err } + progress.SetMessage("start resource export") err = worker.loadResources(ctx) if err != nil { - return worker.jobStatus, err + // TODO: handle better + return progress.Complete(ctx, err), err } - status := worker.jobStatus - if buffered != nil && status.State != provisioning.JobStateError { - status.Message = "pushing changes..." - worker.maybeNotify(ctx) // force notify? - err = buffered.Push(ctx, os.Stdout) - status.Message = "" + // TODO: handle the errors properly + if buffered != nil { + progress.SetMessage("push changes") + if err := buffered.Push(ctx, os.Stdout); err != nil { + return progress.Complete(ctx, err), err + } } - // Add summary info to response - if !status.State.Finished() && err == nil { - status.State = provisioning.JobStateSuccess - status.Message = "" - } - return status, err + return progress.Complete(ctx, nil), err } diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 3ba89f5335d..c6194ea8a6c 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -24,8 +24,7 @@ func MaybeNotifyProgress(threshold time.Duration, fn ProgressFn) ProgressFn { } } -// FIXME: ProgressRecorder should be moved to jobs package and initialized in the queue - +// FIXME: ProgressRecorder should be initialized in the queue type JobResourceResult struct { Name string Resource string @@ -36,32 +35,34 @@ type JobResourceResult struct { } type JobProgressRecorder struct { - total int - ref string - message string - results []JobResourceResult - errors []string - progressFn ProgressFn + total int + ref string + message string + resultCount int + errors []string + progressFn ProgressFn + summaries map[string]*provisioning.JobResourceSummary } func NewJobProgressRecorder(progressFn ProgressFn) *JobProgressRecorder { return &JobProgressRecorder{ progressFn: MaybeNotifyProgress(15*time.Second, progressFn), + summaries: make(map[string]*provisioning.JobResourceSummary), } } func (r *JobProgressRecorder) Record(ctx context.Context, result JobResourceResult) { - if r.results == nil { - r.results = make([]JobResourceResult, 0) - } - r.results = append(r.results, result) + r.resultCount++ if result.Error != nil { logger := logging.FromContext(ctx) logger.Error("job resource operation failed", "err", result.Error, "path", result.Path, "resource", result.Resource, "group", result.Group, "action", result.Action, "name", result.Name) - r.errors = append(r.errors, result.Error.Error()) + if len(r.errors) < 20 { + r.errors = append(r.errors, result.Error.Error()) + } } + r.updateSummary(result) r.notify(ctx) } @@ -90,71 +91,53 @@ func (r *JobProgressRecorder) Errors() []string { } func (r *JobProgressRecorder) summary() []*provisioning.JobResourceSummary { - if len(r.results) == 0 { + if len(r.summaries) == 0 { return nil } - // Group results by resource+group - groupedResults := make(map[string][]JobResourceResult) - for _, result := range r.results { - key := result.Resource + ":" + result.Group - groupedResults[key] = append(groupedResults[key], result) - } - - summaries := make([]*provisioning.JobResourceSummary, 0) - for _, results := range groupedResults { - if len(results) == 0 { - continue - } - - // Count actions - actions := make(map[repository.FileAction]int64) - var errors []string - for _, result := range results { - if result.Error != nil { - errors = append(errors, result.Error.Error()) - } else { - actions[result.Action]++ - } - } - - // Create summary for this group - - // Default to unknown if resource or group is empty - resource := results[0].Resource - if resource == "" { - resource = "unknown" - } - - group := results[0].Group - if group == "" { - group = "unknown" - } - - summary := &provisioning.JobResourceSummary{ - Resource: resource, - Group: group, - Delete: actions[repository.FileActionDeleted], - Update: actions[repository.FileActionUpdated], - Create: actions[repository.FileActionCreated], - Write: actions[repository.FileActionCreated] + actions[repository.FileActionUpdated], - Error: int64(len(errors)), - Noop: actions[repository.FileActionIgnored], - Errors: errors, - } - + summaries := make([]*provisioning.JobResourceSummary, 0, len(r.summaries)) + for _, summary := range r.summaries { summaries = append(summaries, summary) } return summaries } +func (r *JobProgressRecorder) updateSummary(result JobResourceResult) { + key := result.Resource + ":" + result.Group + summary, exists := r.summaries[key] + if !exists { + summary = &provisioning.JobResourceSummary{ + Resource: result.Resource, + Group: result.Group, + } + r.summaries[key] = summary + } + + if result.Error != nil { + summary.Errors = append(summary.Errors, result.Error.Error()) + summary.Error++ + } else { + switch result.Action { + case repository.FileActionDeleted: + summary.Delete++ + case repository.FileActionUpdated: + summary.Update++ + case repository.FileActionCreated: + summary.Create++ + case repository.FileActionIgnored: + summary.Noop++ + } + summary.Write = summary.Create + summary.Update + } +} + func (r *JobProgressRecorder) progress() float64 { if r.total == 0 { return 0 } - return float64(r.total - len(r.results)/r.total*100) + return float64(r.resultCount) / float64(r.total) * 100 } func (r *JobProgressRecorder) notify(ctx context.Context) { diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go index 11e48fe4b15..b76c0886c77 100644 --- a/pkg/registry/apis/provisioning/resources/tree.go +++ b/pkg/registry/apis/provisioning/resources/tree.go @@ -2,6 +2,7 @@ package resources import ( "context" + "fmt" "path" "sort" "strings" @@ -101,7 +102,7 @@ func NewEmptyFolderTree() *FolderTree { func (t *FolderTree) AddUnstructured(item *unstructured.Unstructured, skipRepo string) error { meta, err := utils.MetaAccessor(item) if err != nil { - return err + return fmt.Errorf("extract meta accessor: %w", err) } if meta.GetRepositoryName() == skipRepo { return nil // skip it... already in tree?