From c8f87f441312fce27dd5f403256411f2404133d1 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu, 3 Nov 2022 12:50:32 +0100 Subject: [PATCH 001/926] Alerting: Improving group modal with validation on evaluation interval (#57830) * Show rules list for the group with the For duration, and add validation for keeping all rules in the same group with a valid For * Sort rules by For duration * Add number evaluations column in alert list * Add Error badge in column #evaluations in case of invalid For * Add test for getNumberEvaluationsToStartAlerting method * Move re-usable new InfoIcon component into a separate file in unified components folder * Add edge case for getNumberEvaluationsToStartAlerting method, and change some namings --- .../alerting/unified/RuleList.test.tsx | 34 +- .../alerting/unified/components/InfoIcon.tsx | 11 + .../rule-editor/GrafanaEvaluationBehavior.tsx | 2 +- .../components/rules/EditRuleGroupModal.tsx | 314 +++++++++++++++++- ...etNumberEvaluationsToStartAlerting.test.ts | 17 + .../alerting/unified/state/actions.ts | 48 ++- 6 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 public/app/features/alerting/unified/components/InfoIcon.tsx create mode 100644 public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 51b1ac818c3..2f5c36da86e 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -4,11 +4,12 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; -import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; +import { byRole, byTestId, byText } from 'testing-library-selector'; import { locationService, setDataSourceSrv, logInfo } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; +import * as actions from 'app/features/alerting/unified/state/actions'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto'; @@ -57,9 +58,11 @@ jest.mock('@grafana/runtime', () => { }); jest.spyOn(config, 'getAllDataSources'); +jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]); const mocks = { getAllDataSourcesMock: jest.mocked(config.getAllDataSources), + rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor), api: { discoverFeatures: jest.mocked(discoverFeatures), @@ -121,9 +124,11 @@ const ui = { newRuleButton: byRole('link', { name: 'New alert rule' }), editGroupModal: { - namespaceInput: byLabelText('Namespace'), - ruleGroupInput: byLabelText('Rule group'), - intervalInput: byLabelText('Rule group evaluation interval'), + namespaceInput: byRole('textbox', { hidden: true, name: /namespace/i }), + ruleGroupInput: byRole('textbox', { name: 'Evaluation group', exact: true }), + intervalInput: byRole('textbox', { + name: /Rule group evaluation interval Evaluation interval should be smaller or equal to 'For' values for existing rules in this group./i, + }), saveButton: byRole('button', { name: /Save changes/ }), }, }; @@ -131,6 +136,7 @@ const ui = { describe('RuleList', () => { beforeEach(() => { contextSrv.isEditor = true; + mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]); }); afterEach(() => { @@ -553,9 +559,12 @@ describe('RuleList', () => { // open edit dialog await userEvent.click(ui.editCloudGroupIcon.get(groups[0])); - - expect(ui.editGroupModal.namespaceInput.get()).toHaveValue('namespace1'); - expect(ui.editGroupModal.ruleGroupInput.get()).toHaveValue('group1'); + await expect(screen.getByRole('textbox', { hidden: true, name: /namespace/i })).toHaveDisplayValue( + 'namespace1' + ); + await expect(screen.getByRole('textbox', { name: 'Evaluation group', exact: true })).toHaveDisplayValue( + 'group1' + ); await fn(); }); } @@ -603,9 +612,14 @@ describe('RuleList', () => { testCase('rename just the lotex group', async () => { // make changes to form - await userEvent.clear(ui.editGroupModal.ruleGroupInput.get()); - await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group'); - await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); + await userEvent.clear(screen.getByRole('textbox', { name: 'Evaluation group', exact: true })); + await userEvent.type(screen.getByRole('textbox', { name: 'Evaluation group', exact: true }), 'super group'); + await userEvent.type( + screen.getByRole('textbox', { + name: /rule group evaluation interval evaluation interval should be smaller or equal to 'for' values for existing rules in this group\./i, + }), + '5m' + ); // submit, check that appropriate calls were made await userEvent.click(ui.editGroupModal.saveButton.get()); diff --git a/public/app/features/alerting/unified/components/InfoIcon.tsx b/public/app/features/alerting/unified/components/InfoIcon.tsx new file mode 100644 index 00000000000..e4653b441ca --- /dev/null +++ b/public/app/features/alerting/unified/components/InfoIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Icon, Tooltip } from '@grafana/ui'; + +export function InfoIcon({ text }: { text: string }) { + return ( + {text}}> + + + ); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 031afb34210..5e8e590db6b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -14,7 +14,7 @@ import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { RuleEditorSection } from './RuleEditorSection'; -const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds +export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({ required: { diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index deaf7b3cecf..2dafb3caf12 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -1,18 +1,190 @@ import { css } from '@emotion/css'; import React, { useEffect, useMemo } from 'react'; +import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; -import { Modal, Button, Form, Field, Input, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; +import { Modal, Button, Field, Input, useStyles2, Label, Badge } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { useDispatch } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { RulerRulesConfigDTO, RulerRuleGroupDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; -import { updateLotexNamespaceAndGroupAction } from '../../state/actions'; +import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; import { getRulesSourceName } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; +import { isAlertingRulerRule, isGrafanaRulerRule } from '../../utils/rules'; +import { parsePrometheusDuration } from '../../utils/time'; +import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; +import { InfoIcon } from '../InfoIcon'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; -import { evaluateEveryValidationOptions } from '../rule-editor/GrafanaEvaluationBehavior'; +import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; + +const MINUTE = '1m'; +interface AlertInfo { + alertName: string; + forDuration: string; + evaluationsToFire: number; +} +function ForError({ message }: { message: string }) { + return ; +} + +export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => { + const evalNumberMs = safeParseDurationstr(currentEvaluation); + const forNumber = safeParseDurationstr(forDuration); + if (forNumber === 0 && evalNumberMs !== 0) { + return 1; + } + if (evalNumberMs === 0) { + return 0; + } else { + const evaluationsBeforeCeil = forNumber / evalNumberMs; + return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(forNumber / evalNumberMs) + 1; + } +}; + +export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => { + const emptyAlert: AlertInfo = { + alertName: '', + forDuration: '0s', + evaluationsToFire: 0, + }; + if (isGrafanaRulerRule(alert)) { + return { + alertName: alert.grafana_alert.title, + forDuration: alert.for, + evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation), + }; + } + if (isAlertingRulerRule(alert)) { + return { + alertName: alert.alert, + forDuration: alert.for ?? '1m', + evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation), + }; + } + return emptyAlert; +}; +export const isValidEvaluation = (evaluation: string) => { + try { + const duration = parsePrometheusDuration(evaluation); + + if (duration < MIN_TIME_RANGE_STEP_S * 1000) { + return false; + } + + if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { + return false; + } + + return true; + } catch (error) { + return false; + } +}; + +export const getGroupFromRuler = ( + rulerRules: RulerRulesConfigDTO | null | undefined, + groupName: string, + folderName: string +) => { + const folderObj: Array> = rulerRules ? rulerRules[folderName] : []; + return folderObj?.find((rulerRuleGroup) => rulerRuleGroup.name === groupName); +}; + +export const getIntervalForGroup = ( + rulerRules: RulerRulesConfigDTO | null | undefined, + groupName: string, + folderName: string +) => { + const group = getGroupFromRuler(rulerRules, groupName, folderName); + const interval = group?.interval ?? MINUTE; + return interval; +}; + +export const safeParseDurationstr = (duration: string): number => { + try { + return parsePrometheusDuration(duration); + } catch (e) { + return 0; + } +}; + +type AlertsWithForTableColumnProps = DynamicTableColumnProps; +type AlertsWithForTableProps = DynamicTableItemProps; + +export const RulesForGroupTable = ({ + rulerRules, + groupName, + folderName, +}: { + rulerRules: RulerRulesConfigDTO | null | undefined; + groupName: string; + folderName: string; +}) => { + const styles = useStyles2(getStyles); + const group = getGroupFromRuler(rulerRules, groupName, folderName); + const rules: RulerRuleDTO[] = group?.rules ?? []; + + const { watch } = useFormContext(); + const currentInterval = watch('groupInterval'); + + const rows: AlertsWithForTableProps[] = rules + .slice() + .map((rule: RulerRuleDTO, index) => ({ + id: index, + data: getAlertInfo(rule, currentInterval), + })) + .sort( + (alert1, alert2) => safeParseDurationstr(alert1.data.forDuration) - safeParseDurationstr(alert2.data.forDuration) + ); + + const columns: AlertsWithForTableColumnProps[] = useMemo(() => { + return [ + { + id: 'alertName', + label: 'Alert', + renderCell: ({ data: { alertName } }) => { + return <>{alertName}; + }, + size: 0.6, + }, + { + id: 'for', + label: 'For', + renderCell: ({ data: { forDuration } }) => { + return <>{forDuration}; + }, + size: 0.2, + }, + { + id: 'numberEvaluations', + label: '#Evaluations', + renderCell: ({ data: { evaluationsToFire: numberEvaluations } }) => { + if (!isValidEvaluation(currentInterval)) { + return ; + } + if (numberEvaluations === 0) { + return ; + } else { + return <>{numberEvaluations}; + } + }, + size: 0.2, + }, + ]; + }, [currentInterval]); + + return ( +
+ +
+ ); +}; interface ModalProps { namespace: CombinedRuleNamespace; @@ -32,6 +204,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const dispatch = useDispatch(); const { loading, error, dispatched } = useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState; + const notifyApp = useAppNotification(); const defaultValues = useMemo( (): FormValues => ({ @@ -64,18 +237,77 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { ); }; + const formAPI = useForm({ + mode: 'onBlur', + defaultValues, + shouldFocusError: true, + }); + const { + handleSubmit, + register, + watch, + formState: { isDirty, errors }, + } = formAPI; + + const onInvalid = () => { + notifyApp.error('There are errors in the form. Correct the errors and retry.'); + }; + + const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); + const groupfoldersForSource = rulerRuleRequests[getRulesSourceName(namespace.rulesSource)]; + + const evaluateEveryValidationOptions: RegisterOptions = { + required: { + value: true, + message: 'Required.', + }, + validate: (value: string) => { + try { + const duration = parsePrometheusDuration(value); + + if (duration < MIN_TIME_RANGE_STEP_S * 1000) { + return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; + } + + if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { + return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; + } + if ( + rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, group.name, namespace.name, value).length === 0 + ) { + return true; + } else { + return `Invalid evaluation interval. Evaluation interval should be smaller or equal to 'For' values for existing rules in this group.`; + } + } catch (error) { + return error instanceof Error ? error.message : 'Failed to parse duration'; + } + }, + }; + return ( -
- {({ register, errors, formState: { isDirty }, watch }) => ( + + e.preventDefault()} key={JSON.stringify(defaultValues)}> <> - + + + NameSpace + + + + } + invalid={!!errors.namespaceName} + error={errors.namespaceName?.message} + > - + + + Evaluation group + + + + } + invalid={!!errors.groupName} + error={errors.groupName?.message} + > + + Rule group evaluation interval + + + + } invalid={!!errors.groupInterval} error={errors.groupInterval?.message} > @@ -102,9 +355,23 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { {...register('groupInterval', evaluateEveryValidationOptions)} /> + {checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( )} + {rulerRuleRequests && ( + <> +
List of rules that belong to this group
+
+ #Evaluations column represents the number of evaluations needed before alert starts firing. +
+ + + )} - - )} - + +
); } -const getStyles = () => ({ +const getStyles = (theme: GrafanaTheme2) => ({ modal: css` max-width: 560px; `, + formInput: css` + width: 275px; + & + & { + margin-left: ${theme.spacing(3)}; + } + `, + tableWrapper: css` + margin-top: ${theme.spacing(2)}; + margin-bottom: ${theme.spacing(2)}; + height: 225px; + overflow: auto; + `, + evalRequiredLabel: css` + font-size: ${theme.typography.bodySmall.fontSize}; + `, }); diff --git a/public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts b/public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts new file mode 100644 index 00000000000..f056e741a90 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/getNumberEvaluationsToStartAlerting.test.ts @@ -0,0 +1,17 @@ +import { getNumberEvaluationsToStartAlerting } from './EditRuleGroupModal'; +describe('getNumberEvaluationsToStartAlerting method', () => { + it('should return 0 in case of invalid data', () => { + expect(getNumberEvaluationsToStartAlerting('sd', 'ksdh')).toBe(0); + expect(getNumberEvaluationsToStartAlerting('0s', '1dfa0m')).toBe(0); + }); + it('should return 1 in case of zero For and valid interval', () => { + expect(getNumberEvaluationsToStartAlerting('0s', '10m')).toBe(1); + }); + it('should return correct number in case of valid data', () => { + expect(getNumberEvaluationsToStartAlerting('1m', '10m')).toBe(0); + expect(getNumberEvaluationsToStartAlerting('10m', '10m')).toBe(2); + expect(getNumberEvaluationsToStartAlerting('18m', '10m')).toBe(3); + expect(getNumberEvaluationsToStartAlerting('1h41m', '10m')).toBe(12); + expect(getNumberEvaluationsToStartAlerting('101m', '10m')).toBe(12); + }); +}); diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 45f2224546e..b3c34b512c7 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -1,4 +1,4 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { createAsyncThunk, AsyncThunk } from '@reduxjs/toolkit'; import { isEmpty } from 'lodash'; import { locationService } from '@grafana/runtime'; @@ -60,6 +60,7 @@ import { FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; +import { getAlertInfo, safeParseDurationstr, getGroupFromRuler } from '../components/rules/EditRuleGroupModal'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { @@ -752,8 +753,28 @@ interface UpdateNamespaceAndGroupOptions { groupInterval?: string; } +export const rulesInSameGroupHaveInvalidFor = ( + rulerRules: RulerRulesConfigDTO | null | undefined, + groupName: string, + folderName: string, + everyDuration: string +) => { + const group = getGroupFromRuler(rulerRules, groupName, folderName); + + const rulesSameGroup: RulerRuleDTO[] = group?.rules ?? []; + + return rulesSameGroup.filter((rule: RulerRuleDTO) => { + const { forDuration } = getAlertInfo(rule, everyDuration); + return safeParseDurationstr(forDuration) < safeParseDurationstr(everyDuration); + }); +}; + // allows renaming namespace, renaming group and changing group interval, all in one go -export const updateLotexNamespaceAndGroupAction = createAsyncThunk( +export const updateLotexNamespaceAndGroupAction: AsyncThunk< + void, + UpdateNamespaceAndGroupOptions, + { state: StoreState } +> = createAsyncThunk( 'unifiedalerting/updateLotexNamespaceAndGroup', async (options: UpdateNamespaceAndGroupOptions, thunkAPI): Promise => { return withAppEvents( @@ -790,8 +811,29 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( ) { throw new Error('Nothing changed.'); } - + // validation for new groupInterval + if (groupInterval !== existingGroup.interval) { + const storeState = thunkAPI.getState(); + const groupfoldersForSource = storeState?.unifiedAlerting.rulerRules[rulesSourceName]; + const notValidRules = rulesInSameGroupHaveInvalidFor( + groupfoldersForSource?.result, + groupName, + namespaceName, + groupInterval ?? '1m' + ); + if (notValidRules.length > 0) { + throw new Error( + `These alerts belonging to this group will have an invalid 'For' value: ${notValidRules + .map((rule) => { + const { alertName } = getAlertInfo(rule, groupInterval ?? ''); + return alertName; + }) + .join(',')}` + ); + } + } // if renaming namespace - make new copies of all groups, then delete old namespace + if (newNamespaceName !== namespaceName) { for (const group of rulesResult[namespaceName]) { await setRulerRuleGroup( From 8fe02612b6a6d3d90e9914f1758d0b1dc26d6701 Mon Sep 17 00:00:00 2001 From: Beto Muniz Date: Thu, 3 Nov 2022 09:34:34 -0300 Subject: [PATCH 002/926] Graphite: Allow metric name to use true/false as name (#57996) --- .../app/plugins/datasource/graphite/parser.ts | 2 +- .../datasource/graphite/specs/parser.test.ts | 22 +++++++++++++++++++ .../datasource/graphite/specs/store.test.ts | 16 ++------------ .../datasource/graphite/state/helpers.ts | 15 ++++++++----- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/public/app/plugins/datasource/graphite/parser.ts b/public/app/plugins/datasource/graphite/parser.ts index 8eee34554e1..b0ff68ef140 100644 --- a/public/app/plugins/datasource/graphite/parser.ts +++ b/public/app/plugins/datasource/graphite/parser.ts @@ -68,7 +68,7 @@ export class Parser { return curly; } - if (this.match('identifier') || this.match('number')) { + if (this.match('identifier') || this.match('number') || this.match('bool')) { // hack to handle float numbers in metric segments const parts = this.consumeToken().value.split('.'); if (parts.length === 2) { diff --git a/public/app/plugins/datasource/graphite/specs/parser.test.ts b/public/app/plugins/datasource/graphite/specs/parser.test.ts index 25cabd5d20c..05f4fc108d6 100644 --- a/public/app/plugins/datasource/graphite/specs/parser.test.ts +++ b/public/app/plugins/datasource/graphite/specs/parser.test.ts @@ -21,6 +21,28 @@ describe('when parsing', () => { expect(rootNode.segments[3].value).toBe('5'); }); + it('simple metric expression with "true" boolean in segments', () => { + const parser = new Parser('metric.15_20.5.true'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments.length).toBe(4); + expect(rootNode.segments[1].value).toBe('15_20'); + expect(rootNode.segments[2].value).toBe('5'); + expect(rootNode.segments[3].value).toBe('true'); + }); + + it('simple metric expression with "false" boolean in segments', () => { + const parser = new Parser('metric.false.15_20.5'); + const rootNode = parser.getAst(); + + expect(rootNode.type).toBe('metric'); + expect(rootNode.segments.length).toBe(4); + expect(rootNode.segments[1].value).toBe('false'); + expect(rootNode.segments[2].value).toBe('15_20'); + expect(rootNode.segments[3].value).toBe('5'); + }); + it('simple metric expression with curly braces', () => { const parser = new Parser('metric.se1-{count, max}'); const rootNode = parser.getAst(); diff --git a/public/app/plugins/datasource/graphite/specs/store.test.ts b/public/app/plugins/datasource/graphite/specs/store.test.ts index 262e606e741..4c3919340dc 100644 --- a/public/app/plugins/datasource/graphite/specs/store.test.ts +++ b/public/app/plugins/datasource/graphite/specs/store.test.ts @@ -94,12 +94,6 @@ describe('Graphite actions', () => { expect(ctx.datasource.metricFindQuery.mock.calls[lastCallIndex][0]).toBe('test.prod.*'); }); - it('should delete last segment if no metrics are found', () => { - expect(ctx.state.segments[0].value).toBe('test'); - expect(ctx.state.segments[1].value).toBe('prod'); - expect(ctx.state.segments[2].value).toBe('select metric'); - }); - it('should parse expression and build function model', () => { expect(ctx.state.queryModel.functions.length).toBe(2); }); @@ -116,12 +110,6 @@ describe('Graphite actions', () => { expect(ctx.datasource.metricFindQuery.mock.calls[lastCallIndex][0]).toBe('test.test.*'); }); - it('should delete last segment if no metrics are found', () => { - expect(ctx.state.segments[0].value).toBe('test'); - expect(ctx.state.segments[1].value).toBe('test'); - expect(ctx.state.segments[2].value).toBe('select metric'); - }); - it('should parse expression and build function model', () => { expect(ctx.state.queryModel.functions.length).toBe(2); }); @@ -166,7 +154,7 @@ describe('Graphite actions', () => { }); it('should add 2 segments', () => { - expect(ctx.state.segments.length).toBe(2); + expect(ctx.state.segments.length).toBe(3); }); it('should add function param', () => { @@ -197,7 +185,7 @@ describe('Graphite actions', () => { }); it('should add segments', () => { - expect(ctx.state.segments.length).toBe(3); + expect(ctx.state.segments.length).toBe(4); }); it('should have correct func params', () => { diff --git a/public/app/plugins/datasource/graphite/state/helpers.ts b/public/app/plugins/datasource/graphite/state/helpers.ts index 0f58be18135..8a174d7fa4a 100644 --- a/public/app/plugins/datasource/graphite/state/helpers.ts +++ b/public/app/plugins/datasource/graphite/state/helpers.ts @@ -1,4 +1,4 @@ -import { clone } from 'lodash'; +import { clone, some } from 'lodash'; import { createErrorNotification } from '../../../../core/copy/appNotification'; import { notifyApp } from '../../../../core/reducers/appNotification'; @@ -69,7 +69,8 @@ export async function checkOtherSegments( return; } - const path = state.queryModel.getSegmentPathUpTo(fromIndex + 1); + const currentFromIndex = fromIndex + 1; + const path = state.queryModel.getSegmentPathUpTo(currentFromIndex); if (path === '') { return; } @@ -78,15 +79,17 @@ export async function checkOtherSegments( const segments = await state.datasource.metricFindQuery(path); if (segments.length === 0) { if (path !== '' && modifyLastSegment) { - state.queryModel.segments = state.queryModel.segments.splice(0, fromIndex); - state.segments = state.segments.splice(0, fromIndex); - addSelectMetricSegment(state); + state.queryModel.segments = state.queryModel.segments.splice(0, currentFromIndex); + state.segments = state.segments.splice(0, currentFromIndex); + if (!some(state.segments, { fake: true })) { + addSelectMetricSegment(state); + } } } else if (segments[0].expandable) { if (state.segments.length === fromIndex) { addSelectMetricSegment(state); } else { - await checkOtherSegments(state, fromIndex + 1); + await checkOtherSegments(state, currentFromIndex); } } } catch (err) { From d45fe6e25c310e97fc7f318bf80901e3779f01d7 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu, 3 Nov 2022 09:03:29 -0400 Subject: [PATCH 003/926] Chore: Add NewAnonymousSignedInUser to user service (#57537) --- pkg/services/user/user.go | 1 + pkg/services/user/userimpl/user.go | 26 ++++++++++++++++++ pkg/services/user/userimpl/user_test.go | 36 +++++++++++++++++++++++++ pkg/services/user/usertest/fake.go | 4 +++ 4 files changed, 67 insertions(+) diff --git a/pkg/services/user/user.go b/pkg/services/user/user.go index ecf19c6bfc4..b66962c48f9 100644 --- a/pkg/services/user/user.go +++ b/pkg/services/user/user.go @@ -16,6 +16,7 @@ type Service interface { SetUsingOrg(context.Context, *SetUsingOrgCommand) error GetSignedInUserWithCacheCtx(context.Context, *GetSignedInUserQuery) (*SignedInUser, error) GetSignedInUser(context.Context, *GetSignedInUserQuery) (*SignedInUser, error) + NewAnonymousSignedInUser(context.Context) (*SignedInUser, error) Search(context.Context, *SearchUsersQuery) (*SearchUserQueryResult, error) Disable(context.Context, *DisableUserCommand) error BatchDisableUsers(context.Context, *BatchDisableUsersCommand) error diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index 2325fc64522..96dab700d94 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" @@ -254,6 +255,31 @@ func (s *Service) GetSignedInUser(ctx context.Context, query *user.GetSignedInUs return signedInUser, err } +func (s *Service) NewAnonymousSignedInUser(ctx context.Context) (*user.SignedInUser, error) { + if !s.cfg.AnonymousEnabled { + return nil, fmt.Errorf("anonymous access is disabled") + } + + usr := &user.SignedInUser{ + IsAnonymous: true, + OrgRole: roletype.RoleType(s.cfg.AnonymousOrgRole), + } + + if s.cfg.AnonymousOrgName == "" { + return usr, nil + } + + getOrg := org.GetOrgByNameQuery{Name: s.cfg.AnonymousOrgName} + anonymousOrg, err := s.orgService.GetByName(ctx, &getOrg) + if err != nil { + return nil, err + } + + usr.OrgID = anonymousOrg.ID + usr.OrgName = anonymousOrg.Name + return usr, nil +} + func (s *Service) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { return s.store.Search(ctx, query) } diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index c6611fe2ae5..a371c74789d 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/team/teamtest" @@ -126,6 +127,41 @@ func TestUserService(t *testing.T) { require.NotNil(t, result2) assert.Equal(t, query2.OrgID, result2.OrgID) }) + + t.Run("NewAnonymousSignedInUser", func(t *testing.T) { + t.Run("should error when anonymous access is disabled", func(t *testing.T) { + userService.cfg = setting.NewCfg() + userService.cfg.AnonymousEnabled = false + _, err := userService.NewAnonymousSignedInUser(context.Background()) + require.Error(t, err) + }) + + t.Run("should return user when anonymous access is enabled and org is not set", func(t *testing.T) { + userService.cfg = setting.NewCfg() + userService.cfg.AnonymousEnabled = true + u, err := userService.NewAnonymousSignedInUser(context.Background()) + require.NoError(t, err) + require.Equal(t, true, u.IsAnonymous) + require.Equal(t, int64(0), u.UserID) + require.Equal(t, "", u.OrgName) + require.Equal(t, roletype.RoleType(""), u.OrgRole) + }) + + t.Run("should return user with org info when anonymous access is enabled and org is set", func(t *testing.T) { + userService.cfg = setting.NewCfg() + userService.cfg.AnonymousEnabled = true + userService.cfg.AnonymousOrgName = "anonymous" + userService.cfg.AnonymousOrgRole = "anonymous" + orgService.ExpectedOrg = &org.Org{Name: "anonymous", ID: 123} + u, err := userService.NewAnonymousSignedInUser(context.Background()) + require.NoError(t, err) + require.Equal(t, true, u.IsAnonymous) + require.Equal(t, int64(0), u.UserID) + require.Equal(t, orgService.ExpectedOrg.ID, u.OrgID) + require.Equal(t, orgService.ExpectedOrg.Name, u.OrgName) + require.Equal(t, roletype.RoleType(userService.cfg.AnonymousOrgRole), u.OrgRole) + }) + }) } type FakeUserStore struct { diff --git a/pkg/services/user/usertest/fake.go b/pkg/services/user/usertest/fake.go index 009d6185fbe..0041dd9e8d3 100644 --- a/pkg/services/user/usertest/fake.go +++ b/pkg/services/user/usertest/fake.go @@ -74,6 +74,10 @@ func (f *FakeUserService) GetSignedInUser(ctx context.Context, query *user.GetSi return f.ExpectedSignedInUser, f.ExpectedError } +func (f *FakeUserService) NewAnonymousSignedInUser(ctx context.Context) (*user.SignedInUser, error) { + return f.ExpectedSignedInUser, f.ExpectedError +} + func (f *FakeUserService) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { return &f.ExpectedSearchUsers, f.ExpectedError } From 49e36c5c05e8b328a84e11843d6b2119915653eb Mon Sep 17 00:00:00 2001 From: "lean.dev" <34773040+leandro-deveikis@users.noreply.github.com> Date: Thu, 3 Nov 2022 10:12:27 -0300 Subject: [PATCH 004/926] Public Dashboards: Renaming PubdashFooter (#58137) --- .../PublicDashboardsFooter.tsx} | 12 ++++++------ .../features/dashboard/containers/DashboardPage.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) rename public/app/features/dashboard/components/{PubdashFooter/PubdashFooter.tsx => PublicDashboardFooter/PublicDashboardsFooter.tsx} (77%) diff --git a/public/app/features/dashboard/components/PubdashFooter/PubdashFooter.tsx b/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx similarity index 77% rename from public/app/features/dashboard/components/PubdashFooter/PubdashFooter.tsx rename to public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx index 7eec6b48e7c..4559dae798e 100644 --- a/public/app/features/dashboard/components/PubdashFooter/PubdashFooter.tsx +++ b/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx @@ -4,16 +4,16 @@ import React from 'react'; import { GrafanaTheme2, colorManipulator } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -export interface PublicDashboardFooter { +export interface PublicDashboardFooterCfg { hide: boolean; text: string; logo: string; link: string; } -export const PubdashFooter = function () { +export const PublicDashboardFooter = function () { const styles = useStyles2(getStyles); - const conf = getPubdashFooterConfig(); + const conf = getPublicDashboardFooterConfig(); return conf.hide ? null : (
@@ -26,10 +26,10 @@ export const PubdashFooter = function () { ); }; -export function setPubdashFooterConfigFn(fn: typeof getPubdashFooterConfig) { - getPubdashFooterConfig = fn; +export function setPublicDashboardFooterConfigFn(fn: typeof getPublicDashboardFooterConfig) { + getPublicDashboardFooterConfig = fn; } -export let getPubdashFooterConfig = (): PublicDashboardFooter => ({ +export let getPublicDashboardFooterConfig = (): PublicDashboardFooterCfg => ({ hide: false, text: 'powered by Grafana', logo: 'public/img/grafana_icon.svg', diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 1a2c42a5f45..40c6b6100d8 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -28,7 +28,7 @@ import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt'; import { DashboardSettings } from '../components/DashboardSettings'; import { PanelInspector } from '../components/Inspector/PanelInspector'; import { PanelEditor } from '../components/PanelEditor/PanelEditor'; -import { PubdashFooter } from '../components/PubdashFooter/PubdashFooter'; +import { PublicDashboardFooter } from '../components/PublicDashboardFooter/PublicDashboardsFooter'; import { SubMenu } from '../components/SubMenu/SubMenu'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; import { liveTimer } from '../dashgrid/liveTimer'; @@ -415,7 +415,7 @@ export class UnthemedDashboardPage extends PureComponent { )} { // TODO: assess if there are other places where we may want a footer, which may reveal a better place to add this - isPublic && + isPublic && } ); From 89548df5a425b1a77af8f242d5a1e2d009921008 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 13:18:47 +0000 Subject: [PATCH 005/926] Update dependency @types/ol-ext to v3 (#58140) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8ea775d45ff..066db25861a 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "@types/logfmt": "^1.2.1", "@types/mousetrap": "1.6.10", "@types/node": "16.11.45", - "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@2.3.0", + "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.0.6", "@types/papaparse": "5.3.5", "@types/pluralize": "^0.0.29", "@types/prismjs": "1.26.0", diff --git a/yarn.lock b/yarn.lock index 5419dfa500d..f30ad31d7bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11263,12 +11263,12 @@ __metadata: languageName: node linkType: hard -"@types/ol-ext@npm:@siedlerchr/types-ol-ext@2.3.0": - version: 2.3.0 - resolution: "@siedlerchr/types-ol-ext@npm:2.3.0" +"@types/ol-ext@npm:@siedlerchr/types-ol-ext@3.0.6": + version: 3.0.6 + resolution: "@siedlerchr/types-ol-ext@npm:3.0.6" dependencies: jspdf: ^2.5.1 - checksum: b6652eddf17860df2d42b08ed2639abe21709da91619498ae9ff7cac8b01e0e5359226df0cb501c46248e26288dc7dee704742745af76da5ac00704d904b1596 + checksum: d43e5c8730b04d1469407e24dfa017e38289a6f930e9d514be5811f3426b49c879e53e847f63ef5451de4194ee99eea70a7d1884fc4ab6476f00ad03c78d9f33 languageName: node linkType: hard @@ -21617,7 +21617,7 @@ __metadata: "@types/logfmt": ^1.2.1 "@types/mousetrap": 1.6.10 "@types/node": 16.11.45 - "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@2.3.0" + "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.0.6" "@types/papaparse": 5.3.5 "@types/pluralize": ^0.0.29 "@types/prismjs": 1.26.0 From 5c973e58bd1e59dc212067584cf25dd27a3e9472 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu, 3 Nov 2022 15:21:41 +0200 Subject: [PATCH 006/926] Nested Folders: Add tests for store methods (#57662) * Nested Folders: Add store tests * Fix parent order * Fix update * skip tests! * Export test helpers for now --- pkg/services/folder/folderimpl/sqlstore.go | 186 +++-- .../folder/folderimpl/sqlstore_test.go | 658 +++++++++++++++++- pkg/services/folder/folderimpl/store.go | 10 +- pkg/services/folder/folderimpl/store_fake.go | 12 +- pkg/services/folder/foldertest/foldertest.go | 2 +- pkg/services/folder/model.go | 28 +- pkg/services/org/orgimpl/org.go | 1 + .../sqlstore/migrations/folder_mig.go | 18 +- pkg/util/reverse.go | 10 + pkg/util/reverse_test.go | 15 + 10 files changed, 854 insertions(+), 86 deletions(-) create mode 100644 pkg/util/reverse.go create mode 100644 pkg/util/reverse_test.go diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index de4b18348df..2f2eae569a2 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -2,7 +2,8 @@ package folderimpl import ( "context" - "encoding/binary" + "strings" + "time" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" @@ -10,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) type sqlStore struct { @@ -26,20 +28,44 @@ func ProvideStore(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureManage return &sqlStore{db: db, log: log.New("folder-store"), cfg: cfg, fm: features} } -func (ss *sqlStore) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { - foldr := &folder.Folder{ - OrgID: cmd.OrgID, - UID: cmd.UID, - ParentUID: cmd.ParentUID, - Title: cmd.Title, - Description: cmd.Description, +func (ss *sqlStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) { + if cmd.UID == "" { + return nil, folder.ErrBadRequest.Errorf("missing UID") } + + var foldr *folder.Folder err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - folderID, err := sess.Insert(foldr) + var sqlOrArgs []interface{} + if cmd.ParentUID == "" { + sql := "INSERT INTO folder(org_id, uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?)" + sqlOrArgs = []interface{}{sql, cmd.OrgID, cmd.UID, cmd.Title, cmd.Description, time.Now(), time.Now()} + } else { + if cmd.ParentUID != folder.GeneralFolderUID { + if _, err := ss.Get(ctx, folder.GetFolderQuery{ + UID: &cmd.ParentUID, + OrgID: cmd.OrgID, + }); err != nil { + return err + } + } + sql := "INSERT INTO folder(org_id, uid, parent_uid, title, description, created, updated) VALUES(?, ?, ?, ?, ?, ?, ?)" + sqlOrArgs = []interface{}{sql, cmd.OrgID, cmd.UID, cmd.ParentUID, cmd.Title, cmd.Description, time.Now(), time.Now()} + } + res, err := sess.Exec(sqlOrArgs...) + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to insert folder: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to get last inserted id: %w", err) + } + + foldr, err = ss.Get(ctx, folder.GetFolderQuery{ + ID: &id, + }) if err != nil { return err } - foldr.ID = folderID return nil }) return foldr, err @@ -47,26 +73,85 @@ func (ss *sqlStore) Create(ctx context.Context, cmd *folder.CreateFolderCommand) func (ss *sqlStore) Delete(ctx context.Context, uid string, orgID int64) error { return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Exec("DELETE FROM folder WHERE folder_uid=? AND org_id=?", uid, orgID) - return err + _, err := sess.Exec("DELETE FROM folder WHERE uid=? AND org_id=?", uid, orgID) + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to delete folder: %w", err) + } + /* + affected, err := res.RowsAffected() + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to get affected rows: %w", err) + } + if affected == 0 { + return folder.ErrFolderNotFound.Errorf("folder not found uid:%s org_id:%d", uid, orgID) + } + */ + return nil }) } -func (ss *sqlStore) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { +func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error) { + if cmd.Folder == nil { + return nil, folder.ErrBadRequest.Errorf("invalid update command: missing folder") + } + + cmd.Folder.Updated = time.Now() err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - _, err := sess.ID(cmd.Folder.ID).AllCols().Update(cmd.Folder) - return err + description := cmd.Folder.Description + if cmd.NewDescription != nil { + description = *cmd.NewDescription + } + + title := cmd.Folder.Title + if cmd.NewTitle != nil { + title = *cmd.NewTitle + } + + uid := cmd.Folder.UID + if cmd.NewUID != nil { + uid = *cmd.NewUID + } + + res, err := sess.Exec("UPDATE folder SET description = ?, title = ?, uid = ?, updated = ? WHERE uid = ? AND org_id = ?", description, title, uid, cmd.Folder.Updated, cmd.Folder.UID, cmd.Folder.OrgID) + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to update folder: %w", err) + } + + affected, err := res.RowsAffected() + if err != nil { + return folder.ErrInternal.Errorf("failed to get affected row: %w", err) + } + if affected == 0 { + return folder.ErrInternal.Errorf("no folders are updated") + } + + cmd.Folder.Description = description + cmd.Folder.Title = title + cmd.Folder.UID = uid + return nil }) return cmd.Folder, err } -func (ss *sqlStore) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { - var foldr *folder.Folder +func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.Folder, error) { + foldr := &folder.Folder{} err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - exists, err := sess.Where("uid=? OR id=? OR title=?", cmd.UID, cmd.ID, cmd.Title).Get(foldr) + exists := false + var err error + + switch { + case q.ID != nil: + exists, err = sess.SQL("SELECT * FROM folder WHERE id = ?", q.ID).Get(foldr) + case q.Title != nil: + exists, err = sess.SQL("SELECT * FROM folder WHERE title = ? AND org_id = ?", q.Title, q.OrgID).Get(foldr) + case q.UID != nil: + exists, err = sess.SQL("SELECT * FROM folder WHERE uid = ? AND org_id = ?", q.UID, q.OrgID).Get(foldr) + default: + return folder.ErrBadRequest.Errorf("one of ID, UID, or Title must be included in the command") + } if err != nil { - return err + return folder.ErrDatabaseError.Errorf("failed to get folder: %w", err) } if !exists { return folder.ErrFolderNotFound.Errorf("folder not found") @@ -76,65 +161,62 @@ func (ss *sqlStore) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folde return foldr, err } -func (ss *sqlStore) GetParents(ctx context.Context, cmd *folder.GetParentsQuery) ([]*folder.Folder, error) { +func (ss *sqlStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { var folders []*folder.Folder if ss.db.GetDBType() == migrator.MySQL { - return ss.getParentsMySQL(ctx, cmd) + return ss.getParentsMySQL(ctx, q) } - recQuery := - `WITH RecQry AS ( - SELECT * - FROM folder - UNION ALL - SELECT f.* - FROM folder f INNER JOIN RecQry r - ON f.parent_uid = r.uid + recQuery := ` + WITH RECURSIVE RecQry AS ( + SELECT * FROM folder WHERE uid = ? AND org_id = ? + UNION ALL SELECT f.* FROM folder f INNER JOIN RecQry r ON f.uid = r.parent_uid and f.org_id = r.org_id ) - SELECT * - FROM RecQry` + SELECT * FROM RecQry; + ` err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - res, err := sess.Query(recQuery) + err := sess.SQL(recQuery, q.UID, q.OrgID).Find(&folders) if err != nil { - return err - } - - for _, row := range res { - folders = append(folders, &folder.Folder{ - ID: int64(binary.BigEndian.Uint64(row["id"])), - OrgID: int64(binary.BigEndian.Uint64(row["org_id"])), - UID: string(row["uid"]), - ParentUID: string(row["parent_uid"]), - Title: string(row["title"]), - Description: string(row["description"]), - // CreatedBy: int64(binary.BigEndian.Uint64(row["created_by"])), - }) + return folder.ErrDatabaseError.Errorf("failed to get folder parents: %w", err) } return nil }) - return nil, err + return util.Reverse(folders[1:]), err } -func (ss *sqlStore) GetChildren(ctx context.Context, cmd *folder.GetTreeQuery) ([]*folder.Folder, error) { +func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetTreeQuery) ([]*folder.Folder, error) { var folders []*folder.Folder err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - err := sess.Where("parent_uid=? AND org_id=?", cmd.UID, cmd.OrgID).Find(folders) - return err + sql := strings.Builder{} + sql.Write([]byte("SELECT * FROM folder WHERE parent_uid=? AND org_id=?")) + + if q.Limit != 0 { + var offset int64 = 1 + if q.Page != 0 { + offset = q.Page + } + sql.Write([]byte(ss.db.GetDialect().LimitOffset(q.Limit, offset))) + } + err := sess.SQL(sql.String(), q.UID, q.OrgID).Find(&folders) + if err != nil { + return folder.ErrDatabaseError.Errorf("failed to get folder children: %w", err) + } + return nil }) return folders, err } -func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd *folder.GetParentsQuery) ([]*folder.Folder, error) { +func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) { var foldrs []*folder.Folder var foldr *folder.Folder err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { uid := cmd.UID for uid != folder.GeneralFolderUID && len(foldrs) < 8 { - err := sess.Where("uid=?", uid).Find(foldr) + err := sess.Where("uid=? AND org_id=>", uid, cmd.OrgID).Find(foldr) if err != nil { - return err + return folder.ErrDatabaseError.Errorf("failed to get folder parents: %w", err) } foldrs = append(foldrs, foldr) uid = foldr.ParentUID diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 056a906673b..57fe4c7a793 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -1,19 +1,659 @@ package folderimpl -import "testing" +import ( + "context" + "fmt" + "testing" -func TestCreate(t *testing.T) {} + "github.com/google/go-cmp/cmp" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) -func TestDelete(t *testing.T) {} +func TestIntegrationCreate(t *testing.T) { + t.Skip("skipping until folder migration is merged") -func TestUpdate(t *testing.T) {} + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) -func TestGet(t *testing.T) {} + orgID := CreateOrg(t, db) -func TestGetParent(t *testing.T) {} + t.Run("creating a folder without providing a UID should fail", func(t *testing.T) { + _, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "folder1", + Description: "folder desc", + OrgID: orgID, + }) + require.Error(t, err) + }) -func TestGetParents(t *testing.T) {} + t.Run("creating a folder with unknown parent should fail", func(t *testing.T) { + _, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "folder1", + OrgID: orgID, + ParentUID: "unknown", + Description: "folder desc", + UID: util.GenerateShortUID(), + }) + require.Error(t, err) + }) -func TestGetChildren(t *testing.T) {} + t.Run("creating a folder without providing a parent should default to the general folder", func(t *testing.T) { + uid := util.GenerateShortUID() + f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "folder1", + Description: "folder desc", + OrgID: orgID, + UID: uid, + }) + require.NoError(t, err) -func TestGetDescendents(t *testing.T) {} + t.Cleanup(func() { + err := folderStore.Delete(context.Background(), f.UID, orgID) + require.NoError(t, err) + }) + + assert.Equal(t, "folder1", f.Title) + assert.Equal(t, "folder desc", f.Description) + assert.NotEmpty(t, f.ID) + assert.Equal(t, uid, f.UID) + assert.Equal(t, folder.GeneralFolderUID, f.ParentUID) + + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + UID: &f.UID, + OrgID: orgID, + }) + assert.NoError(t, err) + assert.Equal(t, "folder1", ff.Title) + assert.Equal(t, "folder desc", ff.Description) + assert.Equal(t, accesscontrol.GeneralFolderUID, ff.ParentUID) + + assertAncestorUIDs(t, folderStore, f, []string{folder.GeneralFolderUID}) + }) + + t.Run("creating a folder with a known parent should succeed", func(t *testing.T) { + parentUID := util.GenerateShortUID() + parent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "parent", + OrgID: orgID, + UID: parentUID, + }) + require.NoError(t, err) + require.Equal(t, "parent", parent.Title) + require.NotEmpty(t, parent.ID) + assert.Equal(t, parentUID, parent.UID) + + t.Cleanup(func() { + err := folderStore.Delete(context.Background(), parent.UID, orgID) + require.NoError(t, err) + }) + assertAncestorUIDs(t, folderStore, parent, []string{folder.GeneralFolderUID}) + + uid := util.GenerateShortUID() + f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "folder1", + OrgID: orgID, + ParentUID: parent.UID, + Description: "folder desc", + UID: uid, + }) + require.NoError(t, err) + t.Cleanup(func() { + err := folderStore.Delete(context.Background(), f.UID, orgID) + require.NoError(t, err) + }) + + assert.Equal(t, "folder1", f.Title) + assert.Equal(t, "folder desc", f.Description) + assert.NotEmpty(t, f.ID) + assert.Equal(t, uid, f.UID) + assert.Equal(t, parentUID, f.ParentUID) + + assertAncestorUIDs(t, folderStore, f, []string{folder.GeneralFolderUID, parent.UID}) + assertChildrenUIDs(t, folderStore, parent, []string{f.UID}) + + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + UID: &f.UID, + OrgID: f.OrgID, + }) + assert.NoError(t, err) + assert.Equal(t, "folder1", ff.Title) + assert.Equal(t, "folder desc", ff.Description) + assert.Equal(t, parentUID, ff.ParentUID) + }) + + /* + t.Run("creating a nested folder with the maximum nested folder depth should fail", func(t *testing.T) { + ancestorUIDs := createSubTree(t, folderStore, orgID, accesscontrol.GeneralFolderUID, folder.MaxNestedFolderDepth, "") + + t.Cleanup(func() { + for _, uid := range ancestorUIDs[1:] { + err := folderStore.Delete(context.Background(), uid, orgID) + require.NoError(t, err) + } + }) + + title := fmt.Sprintf("folder-%d", len(ancestorUIDs)) + _, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: "folder1", + OrgID: orgID, + ParentUID: ancestorUIDs[len(ancestorUIDs)-1], + UID: util.GenerateShortUID(), + }) + assert.Error(t, err) + }) + */ +} + +func TestIntegrationDelete(t *testing.T) { + t.Skip("skipping until folder migration is merged") + + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + + orgID := CreateOrg(t, db) + + /* + t.Run("attempt to delete unknown folder should fail", func(t *testing.T) { + err := folderSrore.Delete(context.Background(), "unknown", orgID) + assert.Error(t, err) + }) + */ + + ancestorUIDs := CreateSubTree(t, folderStore, orgID, accesscontrol.GeneralFolderUID, folder.MaxNestedFolderDepth, "") + require.Len(t, ancestorUIDs, folder.MaxNestedFolderDepth+1) + + t.Cleanup(func() { + for _, uid := range ancestorUIDs[1:] { + err := folderStore.Delete(context.Background(), uid, orgID) + require.NoError(t, err) + } + }) + + /* + t.Run("deleting folder with children should fail", func(t *testing.T) { + err = folderSrore.Delete(context.Background(), ancestorUIDs[2], orgID) + require.Error(t, err) + }) + */ + + t.Run("deleting a leaf folder should succeed", func(t *testing.T) { + err := folderStore.Delete(context.Background(), ancestorUIDs[len(ancestorUIDs)-1], orgID) + require.NoError(t, err) + + children, err := folderStore.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: ancestorUIDs[len(ancestorUIDs)-2], + OrgID: orgID, + }) + require.NoError(t, err) + assert.Len(t, children, 0) + }) +} + +func TestIntegrationUpdate(t *testing.T) { + t.Skip("skipping until folder migration is merged") + + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + + orgID := CreateOrg(t, db) + + // create folder + origTitle := "folder1" + origDesc := "folder desc" + f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: origTitle, + Description: origDesc, + OrgID: orgID, + UID: util.GenerateShortUID(), + }) + require.NoError(t, err) + t.Cleanup(func() { + err := folderStore.Delete(context.Background(), f.UID, orgID) + require.NoError(t, err) + }) + + /* + t.Run("updating an unknown folder should fail", func(t *testing.T) { + newTitle := "new title" + newDesc := "new desc" + _, err := folderSrore.Update(context.Background(), &folder.UpdateFolderCommand{ + Folder: f, + NewTitle: &newTitle, + NewDescription: &newDesc, + }) + require.NoError(t, err) + + ff, err := folderSrore.Get(context.Background(), &folder.GetFolderQuery{ + UID: &f.UID, + }) + require.NoError(t, err) + + assert.Equal(t, origTitle, ff.Title) + assert.Equal(t, origDesc, ff.Description) + }) + */ + + t.Run("should not panic in case of bad requests", func(t *testing.T) { + _, err = folderStore.Update(context.Background(), folder.UpdateFolderCommand{}) + require.Error(t, err) + + _, err = folderStore.Update(context.Background(), folder.UpdateFolderCommand{ + Folder: &folder.Folder{}, + }) + require.Error(t, err) + }) + + t.Run("updating a folder should succeed", func(t *testing.T) { + newTitle := "new title" + newDesc := "new desc" + existingUpdated := f.Updated + updated, err := folderStore.Update(context.Background(), folder.UpdateFolderCommand{ + Folder: f, + NewTitle: &newTitle, + NewDescription: &newDesc, + }) + require.NoError(t, err) + + assert.Equal(t, f.UID, updated.UID) + assert.Equal(t, newTitle, updated.Title) + assert.Equal(t, newDesc, updated.Description) + assert.Greater(t, updated.Updated.UnixNano(), existingUpdated.UnixNano()) + + updated, err = folderStore.Get(context.Background(), folder.GetFolderQuery{ + UID: &updated.UID, + OrgID: orgID, + }) + require.NoError(t, err) + assert.Equal(t, newTitle, updated.Title) + assert.Equal(t, newDesc, updated.Description) + }) + + t.Run("updating folder UID should succeed", func(t *testing.T) { + newUID := "new" + existingTitle := f.Title + existingDesc := f.Description + updated, err := folderStore.Update(context.Background(), folder.UpdateFolderCommand{ + Folder: f, + NewUID: &newUID, + }) + require.NoError(t, err) + + assert.Equal(t, newUID, updated.UID) + + updated, err = folderStore.Get(context.Background(), folder.GetFolderQuery{ + UID: &updated.UID, + OrgID: orgID, + }) + require.NoError(t, err) + assert.Equal(t, newUID, updated.UID) + assert.Equal(t, existingTitle, updated.Title) + assert.Equal(t, existingDesc, updated.Description) + }) +} + +func TestIntegrationGet(t *testing.T) { + t.Skip("skipping until folder migration is merged") + + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + + orgID := CreateOrg(t, db) + + // create folder + title1 := "folder1" + desc1 := "folder desc" + uid1 := util.GenerateShortUID() + f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: title1, + Description: desc1, + OrgID: orgID, + UID: uid1, + }) + require.NoError(t, err) + + t.Cleanup(func() { + err := folderStore.Delete(context.Background(), f.UID, orgID) + require.NoError(t, err) + }) + + t.Run("should gently fail in case of bad request", func(t *testing.T) { + _, err = folderStore.Get(context.Background(), folder.GetFolderQuery{}) + require.Error(t, err) + }) + + t.Run("get folder by UID should succeed", func(t *testing.T) { + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + UID: &f.UID, + OrgID: orgID, + }) + require.NoError(t, err) + assert.Equal(t, f.ID, ff.ID) + assert.Equal(t, f.UID, ff.UID) + assert.Equal(t, f.OrgID, ff.OrgID) + assert.Equal(t, f.Title, ff.Title) + assert.Equal(t, f.Description, ff.Description) + //assert.Equal(t, folder.GeneralFolderUID, ff.ParentUID) + assert.NotEmpty(t, ff.Created) + assert.NotEmpty(t, ff.Updated) + }) + + t.Run("get folder by title should succeed", func(t *testing.T) { + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + Title: &f.Title, + OrgID: orgID, + }) + require.NoError(t, err) + assert.Equal(t, f.ID, ff.ID) + assert.Equal(t, f.UID, ff.UID) + assert.Equal(t, f.OrgID, ff.OrgID) + assert.Equal(t, f.Title, ff.Title) + assert.Equal(t, f.Description, ff.Description) + //assert.Equal(t, folder.GeneralFolderUID, ff.ParentUID) + assert.NotEmpty(t, ff.Created) + assert.NotEmpty(t, ff.Updated) + }) + + t.Run("get folder by title should succeed", func(t *testing.T) { + ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{ + ID: &f.ID, + }) + require.NoError(t, err) + assert.Equal(t, f.ID, ff.ID) + assert.Equal(t, f.UID, ff.UID) + assert.Equal(t, f.OrgID, ff.OrgID) + assert.Equal(t, f.Title, ff.Title) + assert.Equal(t, f.Description, ff.Description) + //assert.Equal(t, folder.GeneralFolderUID, ff.ParentUID) + assert.NotEmpty(t, ff.Created) + assert.NotEmpty(t, ff.Updated) + }) +} + +func TestIntegrationGetParents(t *testing.T) { + t.Skip("skipping until folder migration is merged") + + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + + orgID := CreateOrg(t, db) + + // create folder + title1 := "folder1" + desc1 := "folder desc" + uid1 := util.GenerateShortUID() + f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: title1, + Description: desc1, + OrgID: orgID, + UID: uid1, + }) + require.NoError(t, err) + + t.Cleanup(func() { + err := folderStore.Delete(context.Background(), f.UID, orgID) + require.NoError(t, err) + }) + + t.Run("get parents of 1-st level folder should be empty", func(t *testing.T) { + parents, err := folderStore.GetParents(context.Background(), folder.GetParentsQuery{ + UID: f.UID, + OrgID: orgID, + }) + require.NoError(t, err) + require.Empty(t, parents) + }) + + t.Run("get parents of 2-st level folder should not be empty", func(t *testing.T) { + title2 := "folder2" + desc2 := "folder2 desc" + uid2 := util.GenerateShortUID() + + f, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: title2, + Description: desc2, + OrgID: orgID, + UID: uid2, + ParentUID: f.UID, + }) + require.NoError(t, err) + + parents, err := folderStore.GetParents(context.Background(), folder.GetParentsQuery{ + UID: f.UID, + OrgID: orgID, + }) + require.NoError(t, err) + parentUIDs := make([]string, 0) + for _, p := range parents { + parentUIDs = append(parentUIDs, p.UID) + } + require.Equal(t, []string{uid1}, parentUIDs) + }) +} + +func TestIntegrationGetChildren(t *testing.T) { + t.Skip("skipping until folder migration is merged") + + db := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + + orgID := CreateOrg(t, db) + + // create folder + title1 := "folder1" + desc1 := "folder desc" + uid1 := util.GenerateShortUID() + parent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + Title: title1, + Description: desc1, + OrgID: orgID, + UID: uid1, + }) + require.NoError(t, err) + + treeLeaves := CreateLeaves(t, folderStore, parent, 4) + + t.Cleanup(func() { + for _, uid := range treeLeaves { + err := folderStore.Delete(context.Background(), uid, orgID) + require.NoError(t, err) + } + }) + + /* + t.Run("should gently fail in case of bad request", func(t *testing.T) { + _, err := folderStore.GetChildren(context.Background(), folder.GetTreeQuery{}) + require.Error(t, err) + }) + */ + + t.Run("should successfully get all children", func(t *testing.T) { + children, err := folderStore.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: parent.UID, + OrgID: orgID, + }) + require.NoError(t, err) + + childrenUIDs := make([]string, 0, len(children)) + for _, c := range children { + childrenUIDs = append(childrenUIDs, c.UID) + } + + if diff := cmp.Diff(treeLeaves, childrenUIDs); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("query with pagination should work as expected", func(t *testing.T) { + children, err := folderStore.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: parent.UID, + OrgID: orgID, + Limit: 1, + Page: 1, + }) + require.NoError(t, err) + + childrenUIDs := make([]string, 0, len(children)) + for _, c := range children { + childrenUIDs = append(childrenUIDs, c.UID) + } + + if diff := cmp.Diff(treeLeaves[1:2], childrenUIDs); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + + children, err = folderStore.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: parent.UID, + OrgID: orgID, + Limit: 1, + Page: 2, + }) + require.NoError(t, err) + + childrenUIDs = make([]string, 0, len(children)) + for _, c := range children { + childrenUIDs = append(childrenUIDs, c.UID) + } + + if diff := cmp.Diff(treeLeaves[2:3], childrenUIDs); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + + // no page is set + children, err = folderStore.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: parent.UID, + OrgID: orgID, + Limit: 1, + }) + require.NoError(t, err) + + childrenUIDs = make([]string, 0, len(children)) + for _, c := range children { + childrenUIDs = append(childrenUIDs, c.UID) + } + + if diff := cmp.Diff(treeLeaves[1:2], childrenUIDs); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + + // page is set but limit is not set, it should return them all + children, err = folderStore.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: parent.UID, + OrgID: orgID, + Page: 1, + }) + require.NoError(t, err) + + childrenUIDs = make([]string, 0, len(children)) + for _, c := range children { + childrenUIDs = append(childrenUIDs, c.UID) + } + + if diff := cmp.Diff(treeLeaves, childrenUIDs); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } + }) +} + +func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { + t.Helper() + + orgService := orgimpl.ProvideService(db, db.Cfg) + orgID, err := orgService.GetOrCreate(context.Background(), "test-org") + require.NoError(t, err) + t.Cleanup(func() { + err = orgService.Delete(context.Background(), &org.DeleteOrgCommand{ID: orgID}) + require.NoError(t, err) + }) + + return orgID +} + +func CreateSubTree(t *testing.T, store *sqlStore, orgID int64, parentUID string, depth int, prefix string) []string { + t.Helper() + + ancestorUIDs := []string{parentUID} + for i := 0; i < depth; i++ { + parentUID := ancestorUIDs[len(ancestorUIDs)-1] + title := fmt.Sprintf("%sfolder-%d", prefix, i) + f, err := store.Create(context.Background(), folder.CreateFolderCommand{ + Title: title, + OrgID: orgID, + ParentUID: parentUID, + UID: util.GenerateShortUID(), + }) + require.NoError(t, err) + require.Equal(t, title, f.Title) + require.NotEmpty(t, f.ID) + require.NotEmpty(t, f.UID) + + parents, err := store.GetParents(context.Background(), folder.GetParentsQuery{ + UID: f.UID, + OrgID: orgID, + }) + require.NoError(t, err) + parentUIDs := []string{folder.GeneralFolderUID} + for _, p := range parents { + parentUIDs = append(parentUIDs, p.UID) + } + require.Equal(t, ancestorUIDs, parentUIDs) + + ancestorUIDs = append(ancestorUIDs, f.UID) + } + + return ancestorUIDs +} + +func CreateLeaves(t *testing.T, store *sqlStore, parent *folder.Folder, num int) []string { + t.Helper() + + leaves := make([]string, 0) + for i := 0; i < num; i++ { + f, err := store.Create(context.Background(), folder.CreateFolderCommand{ + Title: fmt.Sprintf("folder-%d", i), + UID: util.GenerateShortUID(), + OrgID: parent.OrgID, + ParentUID: parent.UID, + }) + require.NoError(t, err) + leaves = append(leaves, f.UID) + } + return leaves +} + +func assertAncestorUIDs(t *testing.T, store *sqlStore, f *folder.Folder, expected []string) { + t.Helper() + + ancestors, err := store.GetParents(context.Background(), folder.GetParentsQuery{ + UID: f.UID, + OrgID: f.OrgID, + }) + require.NoError(t, err) + actualAncestorsUIDs := []string{folder.GeneralFolderUID} + for _, f := range ancestors { + actualAncestorsUIDs = append(actualAncestorsUIDs, f.UID) + } + assert.Equal(t, expected, actualAncestorsUIDs) +} + +func assertChildrenUIDs(t *testing.T, store *sqlStore, f *folder.Folder, expected []string) { + t.Helper() + + ancestors, err := store.GetChildren(context.Background(), folder.GetTreeQuery{ + UID: f.UID, + OrgID: f.OrgID, + }) + require.NoError(t, err) + actualChildrenUIDs := make([]string, 0) + for _, f := range ancestors { + actualChildrenUIDs = append(actualChildrenUIDs, f.UID) + } + assert.Equal(t, expected, actualChildrenUIDs) +} diff --git a/pkg/services/folder/folderimpl/store.go b/pkg/services/folder/folderimpl/store.go index 2a374343592..9ba0933e4ab 100644 --- a/pkg/services/folder/folderimpl/store.go +++ b/pkg/services/folder/folderimpl/store.go @@ -9,22 +9,22 @@ import ( // store is the interface which a folder store must implement. type store interface { // Create creates a folder and returns the newly-created folder. - Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) + Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) // Delete deletes a folder from the folder store. Delete(ctx context.Context, uid string, orgID int64) error // Update updates the given folder's UID, Title, and Description. // Use Move to change a dashboard's parent ID. - Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) + Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error) // Get returns a folder. - Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) + Get(ctx context.Context, cmd folder.GetFolderQuery) (*folder.Folder, error) // GetParents returns an ordered list of parent folder of the given folder. - GetParents(ctx context.Context, cmd *folder.GetParentsQuery) ([]*folder.Folder, error) + GetParents(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) // GetChildren returns the set of immediate children folders (depth=1) of the // given folder. - GetChildren(ctx context.Context, cmd *folder.GetTreeQuery) ([]*folder.Folder, error) + GetChildren(ctx context.Context, cmd folder.GetTreeQuery) ([]*folder.Folder, error) } diff --git a/pkg/services/folder/folderimpl/store_fake.go b/pkg/services/folder/folderimpl/store_fake.go index 14069e4b3b2..e74c23784de 100644 --- a/pkg/services/folder/folderimpl/store_fake.go +++ b/pkg/services/folder/folderimpl/store_fake.go @@ -14,7 +14,7 @@ type FakeStore struct { var _ store = (*FakeStore)(nil) -func (f *FakeStore) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { +func (f *FakeStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) { return f.ExpectedFolder, f.ExpectedError } @@ -22,22 +22,22 @@ func (f *FakeStore) Delete(ctx context.Context, uid string, orgID int64) error { return f.ExpectedError } -func (f *FakeStore) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { +func (f *FakeStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error) { return f.ExpectedFolder, f.ExpectedError } -func (f *FakeStore) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { +func (f *FakeStore) Move(ctx context.Context, cmd folder.MoveFolderCommand) (*folder.Folder, error) { return f.ExpectedFolder, f.ExpectedError } -func (f *FakeStore) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { +func (f *FakeStore) Get(ctx context.Context, cmd folder.GetFolderQuery) (*folder.Folder, error) { return f.ExpectedFolder, f.ExpectedError } -func (f *FakeStore) GetParents(ctx context.Context, cmd *folder.GetParentsQuery) ([]*folder.Folder, error) { +func (f *FakeStore) GetParents(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) { return f.ExpectedFolders, f.ExpectedError } -func (f *FakeStore) GetChildren(ctx context.Context, cmd *folder.GetTreeQuery) ([]*folder.Folder, error) { +func (f *FakeStore) GetChildren(ctx context.Context, cmd folder.GetTreeQuery) ([]*folder.Folder, error) { return f.ExpectedFolders, f.ExpectedError } diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index 7780e482873..dedd9e1bf15 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -67,7 +67,7 @@ func modelsToFolders(m []*models.Folder) []*folder.Folder { Description: "", // model.Folder does not have a description Created: f.Created, Updated: f.Updated, - UpdatedBy: f.UpdatedBy, + //UpdatedBy: f.UpdatedBy, } } return ret diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 2d27d668279..e6f0a963010 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -6,6 +6,11 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) +var ErrMaximumDepthReached = errutil.NewBase(errutil.StatusBadRequest, "folder.maximum-depth-reached", errutil.WithPublicMessage("Maximum nested folder depth reached")) +var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "folder.bad-request") +var ErrDatabaseError = errutil.NewBase(errutil.StatusInternal, "folder.database-error") +var ErrInternal = errutil.NewBase(errutil.StatusInternal, "folder.internal") + const ( GeneralFolderUID = "general" MaxNestedFolderDepth = 8 @@ -14,10 +19,10 @@ const ( var ErrFolderNotFound = errutil.NewBase(errutil.StatusNotFound, "folder.notFound") type Folder struct { - ID int64 - OrgID int64 - UID string - ParentUID string + ID int64 `xorm:"pk autoincr 'id'"` + OrgID int64 `xorm:"org_id"` + UID string `xorm:"uid"` + ParentUID string `xorm:"parent_uid"` Title string Description string @@ -25,7 +30,8 @@ type Folder struct { Updated time.Time // TODO: validate if this field is required/relevant to folders. - UpdatedBy int64 + // currently there is no such column + // UpdatedBy int64 } // NewFolder tales a title and returns a Folder with the Created and Updated @@ -42,11 +48,11 @@ func NewFolder(title string, description string) *Folder { // CreateFolderCommand captures the information required by the folder service // to create a folder. type CreateFolderCommand struct { - UID string `json:"uid" xorm:"uid"` - OrgID int64 `json:"orgId" xorm:"org_id"` + UID string `json:"uid"` + OrgID int64 `json:"orgId"` Title string `json:"title"` Description string `json:"description"` - ParentUID string `json:"parent_uid" xorm:"parent_uid"` + ParentUID string `json:"parent_uid"` } // UpdateFolderCommand captures the information required by the folder service @@ -77,14 +83,16 @@ type DeleteFolderCommand struct { // Title. type GetFolderQuery struct { UID *string - ID *int + ID *int64 Title *string + OrgID int64 } // GetParentsQuery captures the information required by the folder service to // return a list of all parent folders of a given folder. type GetParentsQuery struct { - UID string `xorm:"uid"` + UID string `xorm:"uid"` + OrgID int64 `xorm:"org_id"` } // GetTreeCommand captures the information required by the folder service to diff --git a/pkg/services/org/orgimpl/org.go b/pkg/services/org/orgimpl/org.go index 8c4f78feb37..ca5539eb9a5 100644 --- a/pkg/services/org/orgimpl/org.go +++ b/pkg/services/org/orgimpl/org.go @@ -141,6 +141,7 @@ func (s *Service) GetOrCreate(ctx context.Context, orgName string) (int64, error orga.Name = MainOrgName orga.ID = int64(s.cfg.AutoAssignOrgId) } else { + orga = &org.Org{} orga.Name = orgName } diff --git a/pkg/services/sqlstore/migrations/folder_mig.go b/pkg/services/sqlstore/migrations/folder_mig.go index 9d08776eb9a..fd9835612dd 100644 --- a/pkg/services/sqlstore/migrations/folder_mig.go +++ b/pkg/services/sqlstore/migrations/folder_mig.go @@ -1,6 +1,8 @@ package migrations import ( + "fmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -13,12 +15,22 @@ func addFolderMigrations(mg *migrator.Migrator) { // table. The *legacy* parent folder ID, stored as folder_id in the // dashboard table, is always going to be "0" so it is safe to convert to a parent UID. mg.AddMigration("copy existing folders from dashboard table", migrator.NewRawSQLMigration( - "INSERT INTO folder (id, uid, org_id, title, parent_uid, created, updated) SELECT id, uid, org_id, title, folder_id, created, updated FROM dashboard WHERE is_folder = 1;", - ).Postgres("INSERT INTO folder (id, uid, org_id, title, parent_uid, created, updated) SELECT id, uid, org_id, title, folder_id, created, updated FROM dashboard WHERE is_folder = true;")) + "INSERT INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1;", + ).Postgres("INSERT INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = true;")) mg.AddMigration("Add index for parent_uid", migrator.NewAddIndexMigration(folderv1(), &migrator.Index{ Cols: []string{"parent_uid", "org_id"}, })) + + mg.AddMigration("Add unique index for folder.uid and folder.org_id", migrator.NewAddIndexMigration(folderv1(), &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"uid", "org_id"}, + })) + + mg.AddMigration("Add unique index for folder.title and folder.parent_uid", migrator.NewAddIndexMigration(folderv1(), &migrator.Index{ + Type: migrator.UniqueIndex, + Cols: []string{"title", "parent_uid"}, + })) } // nolint:unused // this is temporarily unused during feature development @@ -31,7 +43,7 @@ func folderv1() migrator.Table { {Name: "org_id", Type: migrator.DB_BigInt, Nullable: false}, {Name: "title", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, {Name: "description", Type: migrator.DB_NVarchar, Length: 255, Nullable: true}, - {Name: "parent_uid", Type: migrator.DB_NVarchar, Length: 40, Default: folder.GeneralFolderUID}, + {Name: "parent_uid", Type: migrator.DB_NVarchar, Length: 40, Default: fmt.Sprintf("'%s'", folder.GeneralFolderUID)}, {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, }, diff --git a/pkg/util/reverse.go b/pkg/util/reverse.go new file mode 100644 index 00000000000..9e48b58d22e --- /dev/null +++ b/pkg/util/reverse.go @@ -0,0 +1,10 @@ +package util + +// Reverse returns a new slice with reversed order +func Reverse[T comparable](input []T) []T { + output := make([]T, 0, len(input)) + for i := len(input) - 1; i >= 0; i-- { + output = append(output, input[i]) + } + return output +} diff --git a/pkg/util/reverse_test.go b/pkg/util/reverse_test.go new file mode 100644 index 00000000000..2b8f9ef7111 --- /dev/null +++ b/pkg/util/reverse_test.go @@ -0,0 +1,15 @@ +package util + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestReverse(t *testing.T) { + input := []int{1, 2, 3, 4, 5} + + if diff := cmp.Diff([]int{5, 4, 3, 2, 1}, Reverse(input)); diff != "" { + t.Errorf("Result mismatch (-want +got):\n%s", diff) + } +} From 372ba83534e0fd91e1c25d7c3c27a3ac1f68f62c Mon Sep 17 00:00:00 2001 From: Marcos Vinicius Date: Thu, 3 Nov 2022 10:22:07 -0300 Subject: [PATCH 007/926] reduce the number of requests in folder section (#55876) --- public/app/features/search/page/components/FolderSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/search/page/components/FolderSection.tsx b/public/app/features/search/page/components/FolderSection.tsx index 082c820bfe8..7ec926c4709 100644 --- a/public/app/features/search/page/components/FolderSection.tsx +++ b/public/app/features/search/page/components/FolderSection.tsx @@ -82,7 +82,7 @@ export const FolderSection: FC = ({ folderTitle, })); return v; - }, [sectionExpanded, section, tags]); + }, [sectionExpanded, tags]); const onSectionExpand = () => { setSectionExpanded(!sectionExpanded); From 4d88e2b542c62bf287a60af01fddfa2e6de220b4 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Thu, 3 Nov 2022 08:24:01 -0500 Subject: [PATCH 008/926] TimeSeries: more thorough detection of negative values for stacking dir (#57863) --- .../src/components/uPlot/utils.test.ts | 2 +- .../grafana-ui/src/components/uPlot/utils.ts | 48 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 21e9b2d873e..b09afd295fc 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1061,7 +1061,7 @@ describe('auto stacking groups', () => { }, { name: 'd', - values: [-0, -10, -20], + values: [null, -0, null], config: { custom: { stacking: { mode: StackingMode.Normal } } }, }, ], diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 9be91616cb8..f8100a1b7bc 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -116,8 +116,7 @@ export function getStackingGroups(frame: DataFrame) { // will this be stacked up or down after any transforms applied let vals = values.toArray(); let transform = custom.transform; - let firstValue = vals.find((v) => v != null); - let stackDir = getStackDirection(transform, firstValue); + let stackDir = getStackDirection(transform, vals); let drawStyle = custom.drawStyle as GraphDrawStyle; let drawStyle2 = @@ -341,13 +340,48 @@ export function findMidPointYPosition(u: uPlot, idx: number) { return y; } -function getStackDirection(transform: GraphTransform, firstValue: number) { - // Check if first value is negative zero. This can happen with a binary operation transform. - const isNegativeZero = Object.is(firstValue, -0); +function getStackDirection(transform: GraphTransform, data: unknown[]) { + const hasNegSamp = hasNegSample(data); + if (transform === GraphTransform.NegativeY) { - return !isNegativeZero && firstValue >= 0 ? StackDirection.Neg : StackDirection.Pos; + return hasNegSamp ? StackDirection.Pos : StackDirection.Neg; } - return !isNegativeZero && firstValue >= 0 ? StackDirection.Pos : StackDirection.Neg; + return hasNegSamp ? StackDirection.Neg : StackDirection.Pos; +} + +// similar to isLikelyAscendingVector() +function hasNegSample(data: unknown[], samples = 50) { + const len = data.length; + + if (len === 0) { + return false; + } + + // skip leading & trailing nullish + let firstIdx = 0; + let lastIdx = len - 1; + + while (firstIdx <= lastIdx && data[firstIdx] == null) { + firstIdx++; + } + + while (lastIdx >= firstIdx && data[lastIdx] == null) { + lastIdx--; + } + + if (lastIdx >= firstIdx) { + const stride = Math.max(1, Math.floor((lastIdx - firstIdx + 1) / samples)); + + for (let i = firstIdx; i <= lastIdx; i += stride) { + const v = data[i]; + + if (v != null && (v < 0 || Object.is(v, -0))) { + return true; + } + } + } + + return false; } // Dev helpers From 0792ff8e20a8a39a99446df47425b5d0c545493f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Nov 2022 13:37:00 +0000 Subject: [PATCH 009/926] Update dependency eslint-plugin-jest to v27 (#58143) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 066db25861a..29511fbc8c0 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ "eslint": "8.26.0", "eslint-config-prettier": "8.5.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "26.6.0", + "eslint-plugin-jest": "27.1.3", "eslint-plugin-jsdoc": "39.6.2", "eslint-plugin-jsx-a11y": "6.6.1", "eslint-plugin-lodash": "7.4.0", diff --git a/yarn.lock b/yarn.lock index f30ad31d7bf..24e5eac229c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19195,20 +19195,20 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jest@npm:26.6.0": - version: 26.6.0 - resolution: "eslint-plugin-jest@npm:26.6.0" +"eslint-plugin-jest@npm:27.1.3": + version: 27.1.3 + resolution: "eslint-plugin-jest@npm:27.1.3" dependencies: "@typescript-eslint/utils": ^5.10.0 peerDependencies: "@typescript-eslint/eslint-plugin": ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^7.0.0 || ^8.0.0 peerDependenciesMeta: "@typescript-eslint/eslint-plugin": optional: true jest: optional: true - checksum: 5dd60820d5618175e7203b077788476a6f697316b53d77c4bb7037b32073f3d5d539a72dec910eb3f8eedc97c3b28600ba35c5d3bf8c687ade765bb2d0dc77d2 + checksum: 427f39ad4bb50b4e50a1f6aba04962ee3686e25b716d3e4dff47a304c2a352a35b032fec7350b84dc6362838525d93a70f7ae0f961b182c79bf602e90ebb1a55 languageName: node linkType: hard @@ -21695,7 +21695,7 @@ __metadata: eslint: 8.26.0 eslint-config-prettier: 8.5.0 eslint-plugin-import: ^2.26.0 - eslint-plugin-jest: 26.6.0 + eslint-plugin-jest: 27.1.3 eslint-plugin-jsdoc: 39.6.2 eslint-plugin-jsx-a11y: 6.6.1 eslint-plugin-lodash: 7.4.0 From c1c8dc8749f80e9cd747c5b96c9f803b816d32db Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 3 Nov 2022 13:52:58 +0000 Subject: [PATCH 010/926] remove e2e-api-tests + axios dependency (#58148) --- devenv/e2e-api-tests/clearState.test.ts | 7 -- devenv/e2e-api-tests/client.ts | 30 ------ devenv/e2e-api-tests/dashboard.test.ts | 45 --------- devenv/e2e-api-tests/folder.test.ts | 76 --------------- devenv/e2e-api-tests/jest.js | 15 --- devenv/e2e-api-tests/search.test.ts | 27 ------ devenv/e2e-api-tests/setup.ts | 123 ------------------------ devenv/e2e-api-tests/tsconfig.json | 21 ---- devenv/e2e-api-tests/user.test.ts | 22 ----- package.json | 2 - yarn.lock | 21 ---- 11 files changed, 389 deletions(-) delete mode 100644 devenv/e2e-api-tests/clearState.test.ts delete mode 100644 devenv/e2e-api-tests/client.ts delete mode 100644 devenv/e2e-api-tests/dashboard.test.ts delete mode 100644 devenv/e2e-api-tests/folder.test.ts delete mode 100644 devenv/e2e-api-tests/jest.js delete mode 100644 devenv/e2e-api-tests/search.test.ts delete mode 100644 devenv/e2e-api-tests/setup.ts delete mode 100644 devenv/e2e-api-tests/tsconfig.json delete mode 100644 devenv/e2e-api-tests/user.test.ts diff --git a/devenv/e2e-api-tests/clearState.test.ts b/devenv/e2e-api-tests/clearState.test.ts deleted file mode 100644 index 8c5e5c1422b..00000000000 --- a/devenv/e2e-api-tests/clearState.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as setup from './setup'; - -describe.skip('clear state', () => { - it('will clear state', () => { - return setup.clearState(); - }); -}); diff --git a/devenv/e2e-api-tests/client.ts b/devenv/e2e-api-tests/client.ts deleted file mode 100644 index 3a45b30a3f1..00000000000 --- a/devenv/e2e-api-tests/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -const axios = require('axios'); - -export function getClient(options) { - return axios.create({ - baseURL: 'http://localhost:3000', - timeout: 1000, - auth: { - username: options.username, - password: options.password, - }, - }); -} - -export function getAdminClient() { - return getClient({ - username: 'admin', - password: 'admin', - }); -} - -let client = getAdminClient(); - -client.callAs = function (user) { - return getClient({ - username: user.login, - password: 'password', - }); -}; - -export default client; diff --git a/devenv/e2e-api-tests/dashboard.test.ts b/devenv/e2e-api-tests/dashboard.test.ts deleted file mode 100644 index a318158a091..00000000000 --- a/devenv/e2e-api-tests/dashboard.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import client from './client'; -import * as setup from './setup'; - -describe('/api/dashboards', () => { - let state = {}; - - beforeAll(async () => { - state = await setup.ensureState({ - orgName: 'api-test-org', - users: [ - { user: setup.admin, role: 'Admin' }, - { user: setup.editor, role: 'Editor' }, - { user: setup.viewer, role: 'Viewer' }, - ], - admin: setup.admin, - dashboards: [ - { - title: 'aaa', - uid: 'aaa', - }, - { - title: 'bbb', - uid: 'bbb', - }, - ], - }); - }); - - describe('With admin user', () => { - it('can delete dashboard', async () => { - let rsp = await client.callAs(setup.admin).delete(`/api/dashboards/uid/aaa`); - expect(rsp.data.title).toBe('aaa'); - }); - }); - - describe('With viewer user', () => { - it('Cannot delete dashboard', async () => { - let rsp = await setup.expectError(() => { - return client.callAs(setup.viewer).delete(`/api/dashboards/uid/bbb`); - }); - - expect(rsp.response.status).toBe(403); - }); - }); -}); diff --git a/devenv/e2e-api-tests/folder.test.ts b/devenv/e2e-api-tests/folder.test.ts deleted file mode 100644 index 180238a67ad..00000000000 --- a/devenv/e2e-api-tests/folder.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import client from './client'; -import * as setup from './setup'; - -describe('/api/folders', () => { - beforeAll(async () => { - await setup.ensureState({ - orgName: 'api-test-org', - users: [ - { user: setup.admin, role: 'Admin' }, - { user: setup.editor, role: 'Editor' }, - { user: setup.viewer, role: 'Viewer' }, - ], - admin: setup.admin, - folders: [ - { - title: 'Folder 1', - uid: 'f-01', - }, - { - title: 'Folder 2', - uid: 'f-02', - }, - { - title: 'Folder 3', - uid: 'f-03', - }, - ], - }); - }); - - describe('With admin user', () => { - it('can delete folder', async () => { - let rsp = await client.callAs(setup.admin).delete(`/api/folders/f-01`); - expect(rsp.data.title).toBe('Folder 1'); - }); - - it('can update folder', async () => { - let rsp = await client.callAs(setup.admin).put(`/api/folders/f-02`, { - uid: 'f-02', - title: 'Folder 2 upd', - overwrite: true, - }); - expect(rsp.data.title).toBe('Folder 2 upd'); - }); - - it('can update folder uid', async () => { - let rsp = await client.callAs(setup.admin).put(`/api/folders/f-03`, { - uid: 'f-03-upd', - title: 'Folder 3 upd', - overwrite: true, - }); - expect(rsp.data.uid).toBe('f-03-upd'); - expect(rsp.data.title).toBe('Folder 3 upd'); - }); - }); - - describe('With viewer user', () => { - it('Cannot delete folder', async () => { - let rsp = await setup.expectError(() => { - return client.callAs(setup.viewer).delete(`/api/folders/f-02`); - }); - expect(rsp.response.status).toBe(403); - }); - - it('Cannot update folder', async () => { - let rsp = await setup.expectError(() => { - return client.callAs(setup.viewer).put(`/api/folders/f-02`, { - uid: 'f-02', - title: 'Folder 2 upd', - overwrite: true, - }); - }); - expect(rsp.response.status).toBe(403); - }); - }); -}); diff --git a/devenv/e2e-api-tests/jest.js b/devenv/e2e-api-tests/jest.js deleted file mode 100644 index 2471c87cee2..00000000000 --- a/devenv/e2e-api-tests/jest.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - verbose: true, - globals: { - 'ts-jest': { - tsConfigFile: 'tsconfig.json', - }, - }, - transform: { - '^.+\\.tsx?$': '/../../node_modules/ts-jest/preprocessor.js', - }, - moduleDirectories: ['node_modules'], - testRegex: '(\\.|/)(test)\\.ts$', - testEnvironment: 'node', - moduleFileExtensions: ['ts', 'js', 'json'], -}; diff --git a/devenv/e2e-api-tests/search.test.ts b/devenv/e2e-api-tests/search.test.ts deleted file mode 100644 index 91d1ebf0d35..00000000000 --- a/devenv/e2e-api-tests/search.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import client from './client'; -import * as setup from './setup'; - -describe('GET /api/search', () => { - const state = {}; - - beforeAll(async () => { - state = await setup.ensureState({ - orgName: 'api-test-org', - users: [{ user: setup.admin, role: 'Admin' }], - admin: setup.admin, - dashboards: [ - { - title: 'Dashboard in root no permissions', - uid: 'AAA', - }, - ], - }); - }); - - describe('With admin user', () => { - it('should return all dashboards', async () => { - let rsp = await client.callAs(state.admin).get('/api/search'); - expect(rsp.data).toHaveLength(1); - }); - }); -}); diff --git a/devenv/e2e-api-tests/setup.ts b/devenv/e2e-api-tests/setup.ts deleted file mode 100644 index 94b0b20b8b8..00000000000 --- a/devenv/e2e-api-tests/setup.ts +++ /dev/null @@ -1,123 +0,0 @@ -import client from './client'; -import _ from 'lodash;'; - -export const editor = { - email: 'api-test-editor@grafana.com', - login: 'api-test-editor', - password: 'password', - name: 'Api Test Editor', -}; - -export const admin = { - email: 'api-test-admin@grafana.com', - login: 'api-test-admin', - password: 'password', - name: 'Api Test Super', -}; - -export const viewer = { - email: 'api-test-viewer@grafana.com', - login: 'api-test-viewer', - password: 'password', - name: 'Api Test Viewer', -}; - -export async function expectError(callback) { - try { - let rsp = await callback(); - return rsp; - } catch (err) { - return err; - } - - return rsp; -} - -// deletes org if it's already there -export async function getOrg(orgName) { - try { - const rsp = await client.get(`/api/orgs/name/${orgName}`); - await client.delete(`/api/orgs/${rsp.data.id}`); - } catch {} - - const rsp = await client.post(`/api/orgs`, { name: orgName }); - return { name: orgName, id: rsp.data.orgId }; -} - -export async function getUser(user) { - const search = await client.get('/api/users/search', { - params: { query: user.login }, - }); - - if (search.data.totalCount === 1) { - user.id = search.data.users[0].id; - return user; - } - - const rsp = await client.post('/api/admin/users', user); - user.id = rsp.data.id; - - return user; -} - -export async function addUserToOrg(org, user, role) { - const rsp = await client.post(`/api/orgs/${org.id}/users`, { - loginOrEmail: user.login, - role: role, - }); - - return rsp.data; -} - -export async function clearState() { - const admin = await getUser(adminUser); - const rsp = await client.delete(`/api/admin/users/${admin.id}`); - return rsp.data; -} - -export async function setUsingOrg(user, org) { - await client.callAs(user).post(`/api/user/using/${org.id}`); -} - -export async function createDashboard(user, dashboard) { - const rsp = await client.callAs(user).post(`/api/dashboards/db`, { - dashboard: dashboard, - overwrite: true, - }); - dashboard.id = rsp.data.id; - dashboard.url = rsp.data.url; - - return dashboard; -} - -export async function createFolder(user, folder) { - const rsp = await client.callAs(user).post(`/api/folders`, { - uid: folder.uid, - title: folder.title, - overwrite: true, - }); - folder.id = rsp.id; - folder.url = rsp.url; - - return folder; -} - -export async function ensureState(state) { - const org = await getOrg(state.orgName); - - for (let orgUser of state.users) { - const user = await getUser(orgUser.user); - await addUserToOrg(org, user, orgUser.role); - await setUsingOrg(user, org); - } - - for (let dashboard of state.dashboards || []) { - await createDashboard(state.admin, dashboard); - } - - for (let folder of state.folders || []) { - await createFolder(state.admin, folder); - } - - return state; -} diff --git a/devenv/e2e-api-tests/tsconfig.json b/devenv/e2e-api-tests/tsconfig.json deleted file mode 100644 index 322c5f5f278..00000000000 --- a/devenv/e2e-api-tests/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "declaration": false, - "emitDecoratorMetadata": false, - "experimentalDecorators": true, - "inlineSourceMap": false, - "lib": ["es6"], - "module": "commonjs", - "moduleResolution": "node", - "noEmitOnError": false, - "noImplicitAny": false, - "noImplicitReturns": true, - "noImplicitThis": false, - "noImplicitUseStrict": false, - "noUnusedLocals": true, - "sourceMap": true, - "target": "es6" - }, - "include": ["*.ts", "**/*.ts"] -} diff --git a/devenv/e2e-api-tests/user.test.ts b/devenv/e2e-api-tests/user.test.ts deleted file mode 100644 index ef1c927c69e..00000000000 --- a/devenv/e2e-api-tests/user.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import client from './client'; -import * as setup from './setup'; - -describe('GET /api/user', () => { - it('should return current authed user', async () => { - let rsp = await client.get('/api/user'); - expect(rsp.data.login).toBe('admin'); - }); -}); - -describe('PUT /api/user', () => { - it('should update current authed user', async () => { - const user = await setup.getUser(setup.editor); - user.name = 'Updated via test'; - - const rsp = await client.callAs(user).put('/api/user', user); - expect(rsp.data.message).toBe('User updated'); - - const updated = await client.callAs(user).get('/api/user'); - expect(updated.data.name).toBe('Updated via test'); - }); -}); diff --git a/package.json b/package.json index 29511fbc8c0..123f20e53d3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "version": "9.3.0-pre", "repository": "github:grafana/grafana", "scripts": { - "api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js", "build": "yarn i18n:compile && NODE_ENV=production webpack --config scripts/webpack/webpack.prod.js", "build:nominify": "yarn run build --env noMinify=1", "dev": "yarn i18n:compile && webpack --progress --color --config scripts/webpack/webpack.dev.js", @@ -166,7 +165,6 @@ "@typescript-eslint/parser": "5.42.0", "@wojtekmaj/enzyme-adapter-react-17": "0.6.7", "autoprefixer": "10.4.13", - "axios": "0.27.2", "babel-jest": "28.1.3", "babel-loader": "9.0.1", "babel-plugin-angularjs-annotate": "0.10.0", diff --git a/yarn.lock b/yarn.lock index 24e5eac229c..033eb1004d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13915,16 +13915,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:0.27.2": - version: 0.27.2 - resolution: "axios@npm:0.27.2" - dependencies: - follow-redirects: ^1.14.9 - form-data: ^4.0.0 - checksum: 38cb7540465fe8c4102850c4368053c21683af85c5fdf0ea619f9628abbcb59415d1e22ebc8a6390d2bbc9b58a9806c874f139767389c862ec9b772235f06854 - languageName: node - linkType: hard - "axios@npm:^0.25.0": version: 0.25.0 resolution: "axios@npm:0.25.0" @@ -20526,16 +20516,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.9": - version: 1.15.0 - resolution: "follow-redirects@npm:1.15.0" - peerDependenciesMeta: - debug: - optional: true - checksum: eaec81c3e0ae57aae2422e38ad3539d0e7279b3a63f9681eeea319bb683dea67502c4e097136b8ce9721542b4e236e092b6b49e34e326cdd7733c274f0a3f378 - languageName: node - linkType: hard - "for-in@npm:^1.0.2": version: 1.0.2 resolution: "for-in@npm:1.0.2" @@ -21665,7 +21645,6 @@ __metadata: ansicolor: 1.1.100 app: "link:./public/app" autoprefixer: 10.4.13 - axios: 0.27.2 babel-jest: 28.1.3 babel-loader: 9.0.1 babel-plugin-angularjs-annotate: 0.10.0 From f37e53f060ec0312b4bdd36a629e713b98d533c3 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu, 3 Nov 2022 13:54:18 +0000 Subject: [PATCH 011/926] Chore: Migrate more theme v1 usage to v2 (#58121) --- .../src/components/Select/ValueContainer.tsx | 4 +- .../src/components/Typeahead/Typeahead.tsx | 4 +- .../components/VizLegend/VizLegend.story.tsx | 8 ++-- packages/grafana-ui/src/utils/typeahead.ts | 16 ++++---- .../app/core/components/OptionsUI/strings.tsx | 13 ++++--- .../rule-editor/AnnotationsField.tsx | 12 +++--- .../rule-editor/CloudEvaluationBehavior.tsx | 10 ++--- .../components/rule-editor/LabelsField.tsx | 20 +++++----- .../unified/components/rules/ActionButton.tsx | 21 +++++----- .../rules/RuleDetailsMatchingInstances.tsx | 16 ++++---- .../unified/components/rules/RulesFilter.tsx | 16 ++++---- .../components/silences/SilencePeriod.tsx | 10 ++--- .../components/DashNav/DashNavButton.tsx | 13 +++---- .../DashboardLoading/DashboardFailed.tsx | 20 ++++------ .../DashboardLoading/DashboardLoading.tsx | 10 ++--- .../DashboardSettings/ListNewButton.tsx | 10 ++--- .../PanelEditor/DynamicConfigValueEditor.tsx | 13 +++---- .../components/PanelEditor/OptionsPane.tsx | 14 +++---- .../PanelEditor/OverrideCategoryTitle.tsx | 20 +++++----- .../PanelEditor/VisualizationButton.tsx | 26 ++++++------- .../PanelEditor/VisualizationSelectPane.tsx | 24 ++++++------ .../SaveDashboard/SaveDashboardErrorProxy.tsx | 23 ++++++----- .../forms/SaveProvisionedDashboardForm.tsx | 25 +++++------- .../TransformationEditor.tsx | 38 +++++++++---------- .../components/VersionHistory/DiffGroup.tsx | 22 +++++------ .../components/VersionHistory/DiffTitle.tsx | 27 ++++++------- .../VersionHistoryComparison.tsx | 14 +++---- .../VersionHistory/VersionHistoryHeader.tsx | 14 +++---- .../PanelHeaderLoadingIndicator.tsx | 12 +++--- .../ThresholdsEditor/ThresholdsEditor.tsx | 20 +++++----- 30 files changed, 242 insertions(+), 253 deletions(-) diff --git a/packages/grafana-ui/src/components/Select/ValueContainer.tsx b/packages/grafana-ui/src/components/Select/ValueContainer.tsx index 88e6f7713e9..b7c6f957fcb 100644 --- a/packages/grafana-ui/src/components/Select/ValueContainer.tsx +++ b/packages/grafana-ui/src/components/Select/ValueContainer.tsx @@ -1,13 +1,13 @@ import { cx } from '@emotion/css'; import React, { Component, ReactNode } from 'react'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { withTheme2 } from '../../themes/ThemeContext'; import { getSelectStyles } from './getSelectStyles'; -class UnthemedValueContainer extends Component { +class UnthemedValueContainer extends Component { render() { const { children } = this.props; const { selectProps } = this.props; diff --git a/packages/grafana-ui/src/components/Typeahead/Typeahead.tsx b/packages/grafana-ui/src/components/Typeahead/Typeahead.tsx index a733ec1176e..44e24336e12 100644 --- a/packages/grafana-ui/src/components/Typeahead/Typeahead.tsx +++ b/packages/grafana-ui/src/components/Typeahead/Typeahead.tsx @@ -53,7 +53,7 @@ export class Typeahead extends PureComponent { const allItems = flattenGroupItems(this.props.groupedItems); const longestLabel = calculateLongestLabel(allItems); - const { listWidth, listHeight, itemHeight } = calculateListSizes(this.context.v1, allItems, longestLabel); + const { listWidth, listHeight, itemHeight } = calculateListSizes(this.context, allItems, longestLabel); this.setState({ listWidth, listHeight, @@ -87,7 +87,7 @@ export class Typeahead extends PureComponent { if (isEqual(prevProps.groupedItems, this.props.groupedItems) === false) { const allItems = flattenGroupItems(this.props.groupedItems); const longestLabel = calculateLongestLabel(allItems); - const { listWidth, listHeight, itemHeight } = calculateListSizes(this.context.v1, allItems, longestLabel); + const { listWidth, listHeight, itemHeight } = calculateListSizes(this.context, allItems, longestLabel); this.setState({ listWidth, listHeight, itemHeight, allItems, typeaheadIndex: null }); } }; diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx index 505cfade34d..230027dc11e 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx @@ -1,9 +1,9 @@ import { Story, Meta } from '@storybook/react'; import React, { FC, useEffect, useState } from 'react'; -import { DisplayValue, GrafanaTheme } from '@grafana/data'; +import { DisplayValue, GrafanaTheme2 } from '@grafana/data'; import { LegendDisplayMode, LegendPlacement } from '@grafana/schema'; -import { useTheme, VizLegend } from '@grafana/ui'; +import { useTheme2, VizLegend } from '@grafana/ui'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; @@ -43,7 +43,7 @@ interface LegendStoryDemoProps { } const LegendStoryDemo: FC = ({ displayMode, seriesCount, name, placement, stats }) => { - const theme = useTheme(); + const theme = useTheme2(); const [items, setItems] = useState(generateLegendItems(seriesCount, theme, stats)); useEffect(() => { @@ -149,7 +149,7 @@ export const WithValues: Story = ({ containerWidth, seriesCount }) => { function generateLegendItems( numberOfSeries: number, - theme: GrafanaTheme, + theme: GrafanaTheme2, statsToDisplay?: DisplayValue[] ): VizLegendItem[] { const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split(''); diff --git a/packages/grafana-ui/src/utils/typeahead.ts b/packages/grafana-ui/src/utils/typeahead.ts index 856b4f8d305..f0556efebbb 100644 --- a/packages/grafana-ui/src/utils/typeahead.ts +++ b/packages/grafana-ui/src/utils/typeahead.ts @@ -1,6 +1,6 @@ import { default as calculateSize } from 'calculate-size'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { CompletionItemGroup, CompletionItem, CompletionItemKind } from '../types/completion'; @@ -23,10 +23,10 @@ export const calculateLongestLabel = (allItems: CompletionItem[]): string => { }, ''); }; -export const calculateListSizes = (theme: GrafanaTheme, allItems: CompletionItem[], longestLabel: string) => { +export const calculateListSizes = (theme: GrafanaTheme2, allItems: CompletionItem[], longestLabel: string) => { const size = calculateSize(longestLabel, { - font: theme.typography.fontFamily.monospace, - fontSize: theme.typography.size.sm, + font: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.bodySmall.fontSize, fontWeight: 'normal', }); @@ -41,15 +41,15 @@ export const calculateListSizes = (theme: GrafanaTheme, allItems: CompletionItem }; }; -export const calculateItemHeight = (longestLabelHeight: number, theme: GrafanaTheme) => { - const horizontalPadding = parseInt(theme.spacing.sm, 10) * 2; +export const calculateItemHeight = (longestLabelHeight: number, theme: GrafanaTheme2) => { + const horizontalPadding = theme.spacing.gridSize * 2; const itemHeight = longestLabelHeight + horizontalPadding; return itemHeight; }; -export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaTheme) => { - const verticalPadding = parseInt(theme.spacing.sm, 10) + parseInt(theme.spacing.md, 10); +export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaTheme2) => { + const verticalPadding = theme.spacing.gridSize * 3; const maxWidth = 800; const listWidth = Math.min(Math.max(longestLabelWidth + verticalPadding, 200), maxWidth); diff --git a/public/app/core/components/OptionsUI/strings.tsx b/public/app/core/components/OptionsUI/strings.tsx index 88812a3ca77..4fd2aa2da81 100644 --- a/public/app/core/components/OptionsUI/strings.tsx +++ b/public/app/core/components/OptionsUI/strings.tsx @@ -1,8 +1,9 @@ import { css } from '@emotion/css'; import React from 'react'; -import { FieldConfigEditorProps, StringFieldConfigSettings, GrafanaTheme } from '@grafana/data'; -import { stylesFactory, getTheme, Button, Icon, Input } from '@grafana/ui'; +import { FieldConfigEditorProps, StringFieldConfigSettings, GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { stylesFactory, Button, Icon, Input } from '@grafana/ui'; type Props = FieldConfigEditorProps; interface State { @@ -53,7 +54,7 @@ export class StringArrayEditor extends React.PureComponent { render() { const { value, item } = this.props; const { showAdd } = this.state; - const styles = getStyles(getTheme()); + const styles = getStyles(config.theme2); const placeholder = item.settings?.placeholder || 'Add text'; return (
@@ -90,16 +91,16 @@ export class StringArrayEditor extends React.PureComponent { } } -const getStyles = stylesFactory((theme: GrafanaTheme) => { +const getStyles = stylesFactory((theme: GrafanaTheme2) => { return { textInput: css` margin-bottom: 5px; &:hover { - border: 1px solid ${theme.colors.formInputBorderHover}; + border: 1px solid ${theme.components.input.borderHover}; } `, trashIcon: css` - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; cursor: pointer; &:hover { diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx index 85993bd6450..774f1bb4506 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -2,15 +2,15 @@ import { css, cx } from '@emotion/css'; import React, { useCallback } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; -import { GrafanaTheme } from '@grafana/data'; -import { Button, Field, Input, InputControl, Label, TextArea, useStyles } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from '../../types/rule-form'; import { AnnotationKeyInput } from './AnnotationKeyInput'; const AnnotationsField = () => { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const { control, register, @@ -97,7 +97,7 @@ const AnnotationsField = () => { ); }; -const getStyles = (theme: GrafanaTheme) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ annotationValueInput: css` width: 426px; `, @@ -114,7 +114,7 @@ const getStyles = (theme: GrafanaTheme) => ({ flex-direction: column; `, field: css` - margin-bottom: ${theme.spacing.xs}; + margin-bottom: ${theme.spacing(0.5)}; `, flexRow: css` display: flex; @@ -122,7 +122,7 @@ const getStyles = (theme: GrafanaTheme) => ({ justify-content: flex-start; `, flexRowItemMargin: css` - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; `, }); diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx index 406811583c7..fbe226a9c6c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/CloudEvaluationBehavior.tsx @@ -2,8 +2,8 @@ import { css } from '@emotion/css'; import React from 'react'; import { useFormContext } from 'react-hook-form'; -import { GrafanaTheme } from '@grafana/data'; -import { Field, Input, InputControl, Select, useStyles } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Field, Input, InputControl, Select, useStyles2 } from '@grafana/ui'; import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { timeOptions } from '../../utils/time'; @@ -12,7 +12,7 @@ import { PreviewRule } from './PreviewRule'; import { RuleEditorSection } from './RuleEditorSection'; export const CloudEvaluationBehavior = () => { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const { register, control, @@ -57,7 +57,7 @@ export const CloudEvaluationBehavior = () => { ); }; -const getStyles = (theme: GrafanaTheme) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ inlineField: css` margin-bottom: 0; `, @@ -68,6 +68,6 @@ const getStyles = (theme: GrafanaTheme) => ({ align-items: flex-start; `, timeUnit: css` - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; `, }); diff --git a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx index 7d4d96d4943..6d2bad89447 100644 --- a/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/LabelsField.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import React, { FC } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; -import { GrafanaTheme } from '@grafana/data'; -import { Button, Field, Input, InlineLabel, Label, useStyles } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Field, Input, InlineLabel, Label, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from '../../types/rule-form'; @@ -12,7 +12,7 @@ interface Props { } const LabelsField: FC = ({ className }) => { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const { register, control, @@ -94,10 +94,10 @@ const LabelsField: FC = ({ className }) => { ); }; -const getStyles = (theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css` - margin-bottom: ${theme.spacing.xl}; + margin-bottom: ${theme.spacing(4)}; `, flexColumn: css` display: flex; @@ -109,11 +109,11 @@ const getStyles = (theme: GrafanaTheme) => { justify-content: flex-start; & + button { - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; } `, deleteLabelButton: css` - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; align-self: flex-start; `, addLabelButton: css` @@ -127,13 +127,13 @@ const getStyles = (theme: GrafanaTheme) => { align-self: flex-start; width: 28px; justify-content: center; - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; `, labelInput: css` width: 175px; - margin-bottom: ${theme.spacing.sm}; + margin-bottom: ${theme.spacing(1)}; & + & { - margin-left: ${theme.spacing.sm}; + margin-left: ${theme.spacing(1)}; } `, }; diff --git a/public/app/features/alerting/unified/components/rules/ActionButton.tsx b/public/app/features/alerting/unified/components/rules/ActionButton.tsx index d1650a92e25..fafa85c2ecf 100644 --- a/public/app/features/alerting/unified/components/rules/ActionButton.tsx +++ b/public/app/features/alerting/unified/components/rules/ActionButton.tsx @@ -1,17 +1,20 @@ import { css, cx } from '@emotion/css'; import React, { FC } from 'react'; -import { GrafanaTheme } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { Button, ButtonProps } from '@grafana/ui/src/components/Button'; type Props = Omit; -export const ActionButton: FC = ({ className, ...restProps }) => ( - diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx index 362d8ca38db..8b3a2d20576 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import React, { useEffect } from 'react'; -import { GrafanaTheme, SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { stylesFactory, useTheme, Select, MultiSelect, FilterInput, Button } from '@grafana/ui'; +import { useStyles2, Select, MultiSelect, FilterInput, Button } from '@grafana/ui'; import { createDatasourcesList, SortOrder, @@ -28,8 +28,8 @@ export interface Props { exploreId: ExploreId; } -const getStyles = stylesFactory((theme: GrafanaTheme) => { - const bgColor = theme.isLight ? theme.palette.gray5 : theme.palette.dark4; +const getStyles = (theme: GrafanaTheme2) => { + const bgColor = theme.isLight ? theme.v1.palette.gray5 : theme.v1.palette.dark4; return { container: css` display: flex; @@ -44,33 +44,33 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { `, multiselect: css` width: 100%; - margin-bottom: ${theme.spacing.sm}; + margin-bottom: ${theme.spacing(1)}; .gf-form-select-box__multi-value { background-color: ${bgColor}; - padding: ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.xxs} ${theme.spacing.sm}; - border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing(0.25, 0.5, 0.25, 1)}; + border-radius: ${theme.shape.borderRadius(1)}; } `, filterInput: css` - margin-bottom: ${theme.spacing.sm}; + margin-bottom: ${theme.spacing(1)}; `, sort: css` width: 170px; `, footer: css` height: 60px; - margin-top: ${theme.spacing.lg}; + margin-top: ${theme.spacing(3)}; display: flex; justify-content: center; - font-weight: ${theme.typography.weight.light}; - font-size: ${theme.typography.size.sm}; + font-weight: ${theme.typography.fontWeightLight}; + font-size: ${theme.typography.bodySmall.fontSize}; a { - font-weight: ${theme.typography.weight.semibold}; - margin-left: ${theme.spacing.xxs}; + font-weight: ${theme.typography.fontWeightMedium}; + margin-left: ${theme.spacing(0.25)}; } `, }; -}); +}; export function RichHistoryStarredTab(props: Props) { const { @@ -86,8 +86,7 @@ export function RichHistoryStarredTab(props: Props) { exploreId, } = props; - const theme = useTheme(); - const styles = getStyles(theme); + const styles = useStyles2(getStyles); const listOfDatasources = createDatasourcesList(); diff --git a/public/app/features/expressions/components/Condition.tsx b/public/app/features/expressions/components/Condition.tsx index abcb8c90d68..7ca15d80584 100644 --- a/public/app/features/expressions/components/Condition.tsx +++ b/public/app/features/expressions/components/Condition.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; -import React, { FC, FormEvent } from 'react'; +import React, { FormEvent } from 'react'; -import { GrafanaTheme, SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, useStyles } from '@grafana/ui'; +import { Button, ButtonSelect, Icon, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; import alertDef, { EvalFunction } from '../../alerting/state/alertDef'; import { ClassicCondition, ReducerType } from '../types'; @@ -20,8 +20,8 @@ const reducerFunctions = alertDef.reducerTypes.map((rt) => ({ label: rt.text, va const evalOperators = alertDef.evalOperators.map((eo) => ({ label: eo.text, value: eo.value })); const evalFunctions = alertDef.evalFunctions.map((ef) => ({ label: ef.text, value: ef.value })); -export const Condition: FC = ({ condition, index, onChange, onRemoveCondition, refIds }) => { - const styles = useStyles(getStyles); +export const Condition = ({ condition, index, onChange, onRemoveCondition, refIds }: Props) => { + const styles = useStyles2(getStyles); const onEvalOperatorChange = (evalOperator: SelectableValue) => { onChange({ @@ -137,10 +137,10 @@ export const Condition: FC = ({ condition, index, onChange, onRemoveCondi ); }; -const getStyles = (theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { const buttonStyle = css` - color: ${theme.colors.textBlue}; - font-size: ${theme.typography.size.sm}; + color: ${theme.colors.primary.text}; + font-size: ${theme.typography.bodySmall.fontSize}; `; return { buttonSelectText: buttonStyle, @@ -148,12 +148,12 @@ const getStyles = (theme: GrafanaTheme) => { css` display: flex; align-items: center; - border-radius: ${theme.border.radius.sm}; - font-weight: ${theme.typography.weight.semibold}; - border: 1px solid ${theme.colors.border1}; + border-radius: ${theme.shape.borderRadius(1)}; + font-weight: ${theme.typography.fontWeightMedium}; + border: 1px solid ${theme.colors.border.weak}; white-space: nowrap; - padding: 0 ${theme.spacing.sm}; - background-color: ${theme.colors.bodyBg}; + padding: 0 ${theme.spacing(1)}; + background-color: ${theme.colors.background.canvas}; `, buttonStyle ), diff --git a/public/app/features/inspector/DetailText.tsx b/public/app/features/inspector/DetailText.tsx index 6e2868daabf..11972f8417f 100644 --- a/public/app/features/inspector/DetailText.tsx +++ b/public/app/features/inspector/DetailText.tsx @@ -1,17 +1,17 @@ import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; -const getStyles = (theme: GrafanaTheme) => css` +const getStyles = (theme: GrafanaTheme2) => css` margin: 0; - margin-left: ${theme.spacing.md}; - font-size: ${theme.typography.size.sm}; - color: ${theme.colors.textWeak}; + margin-left: ${theme.spacing(2)}; + font-size: ${theme.typography.bodySmall.fontSize}; + color: ${theme.colors.text.secondary}; `; export const DetailText = ({ children }: React.PropsWithChildren<{}>) => { - const collapsedTextStyles = useStyles(getStyles); + const collapsedTextStyles = useStyles2(getStyles); return

{children}

; }; diff --git a/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx b/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx index 92dbf8fb666..c46cae3fdf2 100644 --- a/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/DeleteLibraryPanelModal/DeleteLibraryPanelModal.tsx @@ -1,7 +1,7 @@ import React, { FC, useEffect, useMemo, useReducer } from 'react'; import { LoadingState } from '@grafana/data'; -import { Button, Modal, useStyles } from '@grafana/ui'; +import { Button, Modal, useStyles2 } from '@grafana/ui'; import { getModalStyles } from '../../styles'; import { LibraryElementDTO } from '../../types'; @@ -17,7 +17,7 @@ interface Props { } export const DeleteLibraryPanelModal: FC = ({ libraryPanel, onDismiss, onConfirm }) => { - const styles = useStyles(getModalStyles); + const styles = useStyles2(getModalStyles); const [{ dashboardTitles, loadingState }, dispatch] = useReducer( deleteLibraryPanelModalReducer, initialDeleteLibraryPanelModalState @@ -54,13 +54,13 @@ export const DeleteLibraryPanelModal: FC = ({ libraryPanel, onDismiss, on const LoadingIndicator = () => Loading library panel...; const Confirm = () => { - const styles = useStyles(getModalStyles); + const styles = useStyles2(getModalStyles); return
Do you want to delete this panel?
; }; const HasConnectedDashboards: FC<{ dashboardTitles: string[] }> = ({ dashboardTitles }) => { - const styles = useStyles(getModalStyles); + const styles = useStyles2(getModalStyles); const suffix = dashboardTitles.length === 1 ? 'dashboard.' : 'dashboards.'; const message = `${dashboardTitles.length} ${suffix}`; if (dashboardTitles.length === 0) { diff --git a/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx b/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx index cc06e6bbf43..3560ce189c3 100644 --- a/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx +++ b/public/app/features/library-panels/components/LibraryPanelInfo/LibraryPanelInfo.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import React from 'react'; -import { DateTimeInput, GrafanaTheme } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; +import { DateTimeInput, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { PanelModelWithLibraryPanel } from '../../types'; @@ -12,7 +12,7 @@ interface Props { } export const LibraryPanelInformation = ({ panel, formatDate }: Props) => { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const meta = panel.libraryPanel?.meta; if (!meta) { @@ -42,22 +42,22 @@ export const LibraryPanelInformation = ({ panel, formatDate }: Props) => { ); }; -const getStyles = (theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { return { info: css` line-height: 1; `, libraryPanelInfo: css` - color: ${theme.colors.textSemiWeak}; - font-size: ${theme.typography.size.sm}; + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.bodySmall.fontSize}; `, userAvatar: css` border-radius: 50%; box-sizing: content-box; width: 22px; height: 22px; - padding-left: ${theme.spacing.sm}; - padding-right: ${theme.spacing.sm}; + padding-left: ${theme.spacing(1)}; + padding-right: ${theme.spacing(1)}; `, }; }; diff --git a/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx b/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx index 39431a8c3d3..56cde2c3787 100644 --- a/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import React, { useMemo, useReducer } from 'react'; import { useDebounce } from 'react-use'; -import { GrafanaTheme, LoadingState } from '@grafana/data'; -import { Pagination, useStyles } from '@grafana/ui'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { Pagination, useStyles2 } from '@grafana/ui'; import { LibraryElementDTO } from '../../types'; import { LibraryPanelCard } from '../LibraryPanelCard/LibraryPanelCard'; @@ -34,7 +34,7 @@ export const LibraryPanelsView: React.FC = ({ currentPanelId: currentPanel, perPage: propsPerPage = 40, }) => { - const styles = useStyles(getPanelViewStyles); + const styles = useStyles2(getPanelViewStyles); const [{ libraryPanels, page, perPage, numberOfPages, loadingState, currentPanelId }, dispatch] = useReducer( libraryPanelsViewReducer, { @@ -97,7 +97,7 @@ export const LibraryPanelsView: React.FC = ({ ); }; -const getPanelViewStyles = (theme: GrafanaTheme) => { +const getPanelViewStyles = (theme: GrafanaTheme2) => { return { container: css` display: flex; @@ -107,7 +107,7 @@ const getPanelViewStyles = (theme: GrafanaTheme) => { libraryPanelList: css` max-width: 100%; display: grid; - grid-gap: ${theme.spacing.sm}; + grid-gap: ${theme.spacing(1)}; `, searchHeader: css` display: flex; @@ -118,7 +118,7 @@ const getPanelViewStyles = (theme: GrafanaTheme) => { `, pagination: css` align-self: center; - margin-top: ${theme.spacing.sm}; + margin-top: ${theme.spacing(1)}; `, noPanelsFound: css` label: noPanelsFound; diff --git a/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx b/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx index 34ef35fd7c1..442cce84952 100644 --- a/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react'; import { useAsync, useDebounce } from 'react-use'; -import { Button, Icon, Input, Modal, useStyles } from '@grafana/ui'; +import { Button, Icon, Input, Modal, useStyles2 } from '@grafana/ui'; import { getConnectedDashboards } from '../../state/api'; import { getModalStyles } from '../../styles'; @@ -44,7 +44,7 @@ export const SaveLibraryPanelModal = ({ panel, folderId, isUnsavedPrompt, onDism ); const { saveLibraryPanel } = usePanelSave(); - const styles = useStyles(getModalStyles); + const styles = useStyles2(getModalStyles); const discardAndClose = useCallback(() => { onDiscard(); }, [onDiscard]); diff --git a/public/app/features/library-panels/styles.ts b/public/app/features/library-panels/styles.ts index b76b2e7b43e..bfe0c543695 100644 --- a/public/app/features/library-panels/styles.ts +++ b/public/app/features/library-panels/styles.ts @@ -1,54 +1,54 @@ import { css } from '@emotion/css'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; -export function getModalStyles(theme: GrafanaTheme) { +export function getModalStyles(theme: GrafanaTheme2) { return { myTable: css` max-height: 204px; overflow-y: auto; margin-top: 11px; margin-bottom: 28px; - border-radius: ${theme.border.radius.sm}; - border: 1px solid ${theme.colors.bg3}; - background: ${theme.colors.bg1}; - color: ${theme.colors.textSemiWeak}; - font-size: ${theme.typography.size.md}; + border-radius: ${theme.shape.borderRadius(1)}; + border: 1px solid ${theme.colors.action.hover}; + background: ${theme.colors.background.primary}; + color: ${theme.colors.text.secondary}; + font-size: ${theme.typography.h6.fontSize}; width: 100%; thead { color: #538ade; - font-size: ${theme.typography.size.sm}; + font-size: ${theme.typography.bodySmall.fontSize}; } th, td { padding: 6px 13px; - height: ${theme.spacing.xl}; + height: ${theme.spacing(4)}; } tbody > tr:nth-child(odd) { - background: ${theme.colors.bg2}; + background: ${theme.colors.background.secondary}; } `, noteTextbox: css` - margin-bottom: ${theme.spacing.xl}; + margin-bottom: ${theme.spacing(4)}; `, textInfo: css` - color: ${theme.colors.textSemiWeak}; + color: ${theme.colors.text.secondary}; font-size: ${theme.typography.size.sm}; `, dashboardSearch: css` - margin-top: ${theme.spacing.md}; + margin-top: ${theme.spacing(2)}; `, modal: css` width: 500px; `, modalText: css` - font-size: ${theme.typography.heading.h4}; - color: ${theme.colors.link}; - margin-bottom: calc(${theme.spacing.d} * 2); - padding-top: ${theme.spacing.d}; + font-size: ${theme.typography.h4.fontSize}; + color: ${theme.colors.text.primary}; + margin-bottom: ${theme.spacing(4)}; + padding-top: ${theme.spacing(2)}; `, }; } diff --git a/public/app/features/live/dashboard/DashboardChangedModal.tsx b/public/app/features/live/dashboard/DashboardChangedModal.tsx index 8ac9c6deb7b..24b534208c4 100644 --- a/public/app/features/live/dashboard/DashboardChangedModal.tsx +++ b/public/app/features/live/dashboard/DashboardChangedModal.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React, { PureComponent } from 'react'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Modal, stylesFactory } from '@grafana/ui'; @@ -59,7 +59,7 @@ export class DashboardChangedModal extends PureComponent { render() { const { event } = this.props; const { dismiss } = this.state; - const styles = getStyles(config.theme); + const styles = getStyles(config.theme2); const isDelete = event?.action === DashboardEventAction.Deleted; @@ -98,7 +98,7 @@ export class DashboardChangedModal extends PureComponent { } } -const getStyles = stylesFactory((theme: GrafanaTheme) => { +const getStyles = stylesFactory((theme: GrafanaTheme2) => { return { modal: css` width: 500px; @@ -106,13 +106,13 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { radioItem: css` margin: 0; font-size: ${theme.typography.size.sm}; - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; padding: 10px; cursor: pointer; width: 100%; &:hover { - background: ${theme.colors.bgBlue1}; + background: ${theme.colors.primary.main}; color: ${theme.colors.text}; } `, diff --git a/public/app/features/live/pages/CloudAdminPage.tsx b/public/app/features/live/pages/CloudAdminPage.tsx index 449c4c06c33..9a6fd74ca26 100644 --- a/public/app/features/live/pages/CloudAdminPage.tsx +++ b/public/app/features/live/pages/CloudAdminPage.tsx @@ -1,9 +1,7 @@ import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; -import { GrafanaTheme } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import { useStyles } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; @@ -13,7 +11,6 @@ export default function CloudAdminPage() { const navModel = useNavModel('live-cloud'); const [cloud, setCloud] = useState([]); const [error, setError] = useState(); - const styles = useStyles(getStyles); useEffect(() => { getBackendSrv() @@ -47,10 +44,8 @@ export default function CloudAdminPage() { ); } -const getStyles = (theme: GrafanaTheme) => { - return { - row: css` - cursor: pointer; - `, - }; +const styles = { + row: css` + cursor: pointer; + `, }; diff --git a/public/app/features/live/pages/PipelineTable.tsx b/public/app/features/live/pages/PipelineTable.tsx index 308b486ac72..791aa62f9d2 100644 --- a/public/app/features/live/pages/PipelineTable.tsx +++ b/public/app/features/live/pages/PipelineTable.tsx @@ -1,9 +1,8 @@ import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; -import { GrafanaTheme } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import { Tag, useStyles, IconButton } from '@grafana/ui'; +import { Tag, IconButton } from '@grafana/ui'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { RuleModal } from './RuleModal'; @@ -27,7 +26,6 @@ export const PipelineTable = (props: Props) => { const [isOpen, setOpen] = useState(false); const [selectedRule, setSelectedRule] = useState(); const [clickColumn, setClickColumn] = useState('converter'); - const styles = useStyles(getStyles); const onRowClick = (rule: Rule, event?: any) => { if (!rule) { @@ -137,10 +135,8 @@ export const PipelineTable = (props: Props) => { ); }; -const getStyles = (theme: GrafanaTheme) => { - return { - row: css` - cursor: pointer; - `, - }; +const styles = { + row: css` + cursor: pointer; + `, }; diff --git a/public/app/features/live/pages/RuleModal.tsx b/public/app/features/live/pages/RuleModal.tsx index 2f37514b388..8d1465b07ec 100644 --- a/public/app/features/live/pages/RuleModal.tsx +++ b/public/app/features/live/pages/RuleModal.tsx @@ -1,9 +1,8 @@ import { css } from '@emotion/css'; import React, { useState, useMemo } from 'react'; -import { GrafanaTheme } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import { Modal, TabContent, TabsBar, Tab, Button, useStyles } from '@grafana/ui'; +import { Modal, TabContent, TabsBar, Tab, Button } from '@grafana/ui'; import { RuleSettingsArray } from './RuleSettingsArray'; import { RuleSettingsEditor } from './RuleSettingsEditor'; @@ -39,7 +38,6 @@ export const RuleModal = (props: Props) => { const [hasChange, setChange] = useState(false); const [ruleSetting, setRuleSetting] = useState(activeTab?.type ? rule?.settings?.[activeTab.type] : undefined); const [entitiesInfo, setEntitiesInfo] = useState(); - const styles = useStyles(getStyles); const onRuleSettingChange = (value: RuleSetting | RuleSetting[]) => { setChange(true); @@ -123,10 +121,8 @@ export const RuleModal = (props: Props) => { ); }; -const getStyles = (theme: GrafanaTheme) => { - return { - save: css` - margin-top: 5px; - `, - }; +const styles = { + save: css` + margin-top: 5px; + `, }; diff --git a/public/app/features/live/pages/RuleTest.tsx b/public/app/features/live/pages/RuleTest.tsx index d4e7be155b9..d8fc6516e9e 100644 --- a/public/app/features/live/pages/RuleTest.tsx +++ b/public/app/features/live/pages/RuleTest.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; -import { dataFrameFromJSON, getDisplayProcessor, GrafanaTheme } from '@grafana/data'; +import { dataFrameFromJSON, getDisplayProcessor } from '@grafana/data'; import { getBackendSrv, config } from '@grafana/runtime'; -import { Button, CodeEditor, Table, useStyles, Field } from '@grafana/ui'; +import { Button, CodeEditor, Table, Field } from '@grafana/ui'; import { ChannelFrame, Rule } from './types'; @@ -14,7 +14,6 @@ interface Props { export const RuleTest = (props: Props) => { const [response, setResponse] = useState(); const [data, setData] = useState(); - const styles = useStyles(getStyles); const onBlur = (text: string) => { setData(text); @@ -72,10 +71,8 @@ export const RuleTest = (props: Props) => { ); }; -const getStyles = (theme: GrafanaTheme) => { - return { - margin: css` - margin-bottom: 15px; - `, - }; +const styles = { + margin: css` + margin-bottom: 15px; + `, }; diff --git a/public/app/features/plugins/components/PluginsErrorsInfo.tsx b/public/app/features/plugins/components/PluginsErrorsInfo.tsx index 9d085ee0fcf..b550cfcea67 100644 --- a/public/app/features/plugins/components/PluginsErrorsInfo.tsx +++ b/public/app/features/plugins/components/PluginsErrorsInfo.tsx @@ -1,16 +1,16 @@ import { css } from '@emotion/css'; import React from 'react'; -import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data'; +import { GrafanaTheme2, PluginErrorCode, PluginSignatureStatus } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { HorizontalGroup, InfoBox, List, PluginSignatureBadge, useTheme } from '@grafana/ui'; +import { HorizontalGroup, InfoBox, List, PluginSignatureBadge, useStyles2 } from '@grafana/ui'; import { useGetErrors, useFetchStatus } from '../admin/state/hooks'; -export function PluginsErrorsInfo(): React.ReactElement | null { +export function PluginsErrorsInfo() { const errors = useGetErrors(); const { isLoading } = useFetchStatus(); - const theme = useTheme(); + const styles = useStyles2(getStyles); if (isLoading || errors.length === 0) { return null; @@ -31,22 +31,14 @@ export function PluginsErrorsInfo(): React.ReactElement | null { The following plugins are disabled and not shown in the list below: ( -
+
{error.pluginId}
@@ -69,3 +61,17 @@ function mapPluginErrorCodeToSignatureStatus(code: PluginErrorCode) { return PluginSignatureStatus.missing; } } + +function getStyles(theme: GrafanaTheme2) { + return { + list: css({ + listStyleType: 'circle', + }), + wrapper: css({ + marginTop: theme.spacing(1), + }), + badge: css({ + marginTop: 0, + }), + }; +} diff --git a/public/app/features/query/components/QueryEditorRowHeader.tsx b/public/app/features/query/components/QueryEditorRowHeader.tsx index 620a7781efd..4122e04eebc 100644 --- a/public/app/features/query/components/QueryEditorRowHeader.tsx +++ b/public/app/features/query/components/QueryEditorRowHeader.tsx @@ -1,10 +1,10 @@ import { css, cx } from '@emotion/css'; import React, { ReactNode, useState } from 'react'; -import { DataQuery, DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data'; +import { DataQuery, DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { DataSourcePicker } from '@grafana/runtime'; -import { Icon, Input, FieldValidationMessage, useStyles } from '@grafana/ui'; +import { Icon, Input, FieldValidationMessage, useStyles2 } from '@grafana/ui'; export interface Props { query: TQuery; @@ -22,7 +22,7 @@ export interface Props { export const QueryEditorRowHeader = (props: Props) => { const { query, queries, onClick, onChange, collapsedText, renderExtras, disabled } = props; - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const [isEditing, setIsEditing] = useState(false); const [validationError, setValidationError] = useState(null); @@ -146,31 +146,31 @@ const renderDataSource = ( ); }; -const getStyles = (theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { return { wrapper: css` label: Wrapper; display: flex; align-items: center; - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; `, queryNameWrapper: css` display: flex; cursor: pointer; border: 1px solid transparent; - border-radius: ${theme.border.radius.md}; + border-radius: ${theme.shape.borderRadius(2)}; align-items: center; - padding: 0 0 0 ${theme.spacing.xs}; + padding: 0 0 0 ${theme.spacing(0.5)}; margin: 0; background: transparent; &:hover { - background: ${theme.colors.bg3}; - border: 1px dashed ${theme.colors.border3}; + background: ${theme.colors.action.hover}; + border: 1px dashed ${theme.colors.border.strong}; } &:focus { - border: 2px solid ${theme.colors.formInputBorderActive}; + border: 2px solid ${theme.colors.primary.border}; } &:hover, @@ -181,15 +181,15 @@ const getStyles = (theme: GrafanaTheme) => { } `, queryName: css` - font-weight: ${theme.typography.weight.semibold}; - color: ${theme.colors.textBlue}; + font-weight: ${theme.typography.fontWeightMedium}; + color: ${theme.colors.primary.text}; cursor: pointer; overflow: hidden; - margin-left: ${theme.spacing.xs}; + margin-left: ${theme.spacing(0.5)}; `, queryEditIcon: cx( css` - margin-left: ${theme.spacing.md}; + margin-left: ${theme.spacing(2)}; visibility: hidden; `, 'query-name-edit-icon' @@ -199,10 +199,10 @@ const getStyles = (theme: GrafanaTheme) => { margin: -4px 0; `, collapsedText: css` - font-weight: ${theme.typography.weight.regular}; - font-size: ${theme.typography.size.sm}; - color: ${theme.colors.textWeak}; - padding-left: ${theme.spacing.sm}; + font-weight: ${theme.typography.fontWeightRegular}; + font-size: ${theme.typography.bodySmall.fontSize}; + color: ${theme.colors.text.secondary}; + padding-left: ${theme.spacing(1)}; align-items: center; overflow: hidden; font-style: italic; @@ -210,9 +210,9 @@ const getStyles = (theme: GrafanaTheme) => { text-overflow: ellipsis; `, contextInfo: css` - font-size: ${theme.typography.size.sm}; + font-size: ${theme.typography.bodySmall.fontSize}; font-style: italic; - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; padding-left: 10px; `, itemWrapper: css` diff --git a/public/app/features/search/page/components/ConfirmDeleteModal.tsx b/public/app/features/search/page/components/ConfirmDeleteModal.tsx index 7850be6819b..b6a548dadf4 100644 --- a/public/app/features/search/page/components/ConfirmDeleteModal.tsx +++ b/public/app/features/search/page/components/ConfirmDeleteModal.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import React, { FC } from 'react'; -import { GrafanaTheme } from '@grafana/data'; -import { ConfirmModal, stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { ConfirmModal, useStyles2 } from '@grafana/ui'; import { deleteFoldersAndDashboards } from 'app/features/manage-dashboards/state/actions'; import { OnMoveOrDeleleSelectedItems } from '../../types'; @@ -15,8 +15,7 @@ interface Props { } export const ConfirmDeleteModal: FC = ({ results, onDeleteItems, isOpen, onDismiss }) => { - const theme = useTheme(); - const styles = getStyles(theme); + const styles = useStyles2(getStyles); const dashboards = Array.from(results.get('dashboard') ?? []); const folders = Array.from(results.get('folder') ?? []); @@ -61,11 +60,9 @@ export const ConfirmDeleteModal: FC = ({ results, onDeleteItems, isOpen, ) : null; }; -const getStyles = stylesFactory((theme: GrafanaTheme) => { - return { - subtitle: css` - font-size: ${theme.typography.size.base}; - padding-top: ${theme.spacing.md}; - `, - }; +const getStyles = (theme: GrafanaTheme2) => ({ + subtitle: css` + font-size: ${theme.typography.fontSize}px; + padding-top: ${theme.spacing(2)}; + `, }); diff --git a/public/app/features/search/page/components/FolderSection.tsx b/public/app/features/search/page/components/FolderSection.tsx index 7ec926c4709..1ca6f74a29a 100644 --- a/public/app/features/search/page/components/FolderSection.tsx +++ b/public/app/features/search/page/components/FolderSection.tsx @@ -1,10 +1,10 @@ import { css, cx } from '@emotion/css'; -import React, { FC } from 'react'; +import React, { useCallback } from 'react'; import { useAsync, useLocalStorage } from 'react-use'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Card, Checkbox, CollapsableSection, Icon, IconName, Spinner, stylesFactory, useTheme } from '@grafana/ui'; +import { Card, Checkbox, CollapsableSection, Icon, IconName, Spinner, useStyles2 } from '@grafana/ui'; import { getSectionStorageKey } from 'app/features/search/utils'; import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId'; @@ -33,7 +33,7 @@ interface SectionHeaderProps { tags?: string[]; } -export const FolderSection: FC = ({ +export const FolderSection = ({ section, selectionToggle, onClickItem, @@ -41,10 +41,14 @@ export const FolderSection: FC = ({ selection, renderStandaloneBody, tags, -}) => { +}: SectionHeaderProps) => { const editable = selectionToggle != null; - const theme = useTheme(); - const styles = getSectionHeaderStyles(theme, section.selected, editable); + const styles = useStyles2( + useCallback( + (theme: GrafanaTheme2) => getSectionHeaderStyles(theme, section.selected, editable), + [section.selected, editable] + ) + ); const [sectionExpanded, setSectionExpanded] = useLocalStorage(getSectionStorageKey(section.title), false); const results = useAsync(async () => { @@ -194,8 +198,8 @@ export const FolderSection: FC = ({ ); }; -const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = false, editable: boolean) => { - const { sm } = theme.spacing; +const getSectionHeaderStyles = (theme: GrafanaTheme2, selected = false, editable: boolean) => { + const sm = theme.spacing(1); return { wrapper: cx( css` @@ -203,7 +207,7 @@ const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = fa font-size: ${theme.typography.size.base}; padding: 12px; border-bottom: none; - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; z-index: 1; &:hover, @@ -240,7 +244,7 @@ const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = fa `, link: css` padding: 2px 10px 0; - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; opacity: 0; transition: opacity 150ms ease-in-out; `, @@ -257,4 +261,4 @@ const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = fa padding-bottom: 1rem; `, }; -}); +}; diff --git a/public/app/features/search/page/components/MoveToFolderModal.tsx b/public/app/features/search/page/components/MoveToFolderModal.tsx index 3675097ec7e..ef53bb2977c 100644 --- a/public/app/features/search/page/components/MoveToFolderModal.tsx +++ b/public/app/features/search/page/components/MoveToFolderModal.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import React, { FC, useState } from 'react'; -import { GrafanaTheme } from '@grafana/data'; -import { Button, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, HorizontalGroup, Modal, useStyles2 } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { useAppNotification } from 'app/core/copy/appNotification'; import { moveDashboards } from 'app/features/manage-dashboards/state/actions'; @@ -19,8 +19,7 @@ interface Props { export const MoveToFolderModal: FC = ({ results, onMoveItems, isOpen, onDismiss }) => { const [folder, setFolder] = useState(null); - const theme = useTheme(); - const styles = getStyles(theme); + const styles = useStyles2(getStyles); const notifyApp = useAppNotification(); const selectedDashboards = Array.from(results.get('dashboard') ?? []); const [moving, setMoving] = useState(false); @@ -80,13 +79,13 @@ export const MoveToFolderModal: FC = ({ results, onMoveItems, isOpen, onD ) : null; }; -const getStyles = stylesFactory((theme: GrafanaTheme) => { +const getStyles = (theme: GrafanaTheme2) => { return { modal: css` width: 500px; `, content: css` - margin-bottom: ${theme.spacing.lg}; + margin-bottom: ${theme.spacing(3)}; `, }; -}); +}; diff --git a/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx index 3fc97f52fa1..72b6a5312b5 100644 --- a/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx +++ b/public/app/features/transformers/editors/OrganizeFieldsTransformerEditor.tsx @@ -4,14 +4,14 @@ import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautif import { DataTransformerID, - GrafanaTheme, + GrafanaTheme2, standardTransformers, TransformerRegistryItem, TransformerUIProps, } from '@grafana/data'; import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order'; import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize'; -import { stylesFactory, useTheme, Input, IconButton, Icon, FieldValidationMessage } from '@grafana/ui'; +import { Input, IconButton, Icon, FieldValidationMessage, useStyles2 } from '@grafana/ui'; import { useAllFieldNamesFromDataFrames } from '../utils'; @@ -117,16 +117,15 @@ interface DraggableFieldProps { onRenameField: (from: string, to: string) => void; } -const DraggableFieldName: React.FC = ({ +const DraggableFieldName = ({ fieldName, renamedFieldName, index, visible, onToggleVisibility, onRenameField, -}) => { - const theme = useTheme(); - const styles = getFieldNameStyles(theme); +}: DraggableFieldProps) => { + const styles = useStyles2(getFieldNameStyles); return ( @@ -166,25 +165,25 @@ const DraggableFieldName: React.FC = ({ DraggableFieldName.displayName = 'DraggableFieldName'; -const getFieldNameStyles = stylesFactory((theme: GrafanaTheme) => ({ +const getFieldNameStyles = (theme: GrafanaTheme2) => ({ toggle: css` margin: 0 8px; - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; `, draggable: css` opacity: 0.4; &:hover { - color: ${theme.colors.textStrong}; + color: ${theme.colors.text.maxContrast}; } `, name: css` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: ${theme.typography.size.sm}; - font-weight: ${theme.typography.weight.semibold}; + font-size: ${theme.typography.bodySmall.fontSize}; + font-weight: ${theme.typography.fontWeightMedium}; `, -})); +}); const reorderToIndex = (fieldNames: string[], startIndex: number, endIndex: number) => { const result = Array.from(fieldNames); diff --git a/public/app/features/variables/editor/VariableSelectField.tsx b/public/app/features/variables/editor/VariableSelectField.tsx index 801ca3f74d8..ef7bb998369 100644 --- a/public/app/features/variables/editor/VariableSelectField.tsx +++ b/public/app/features/variables/editor/VariableSelectField.tsx @@ -1,8 +1,8 @@ import { css } from '@emotion/css'; import React, { PropsWithChildren, ReactElement } from 'react'; -import { GrafanaTheme, SelectableValue } from '@grafana/data'; -import { Field, Select, useStyles } from '@grafana/ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Field, Select, useStyles2 } from '@grafana/ui'; import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId'; interface VariableSelectFieldProps { @@ -24,7 +24,7 @@ export function VariableSelectField({ testId, width, }: PropsWithChildren>): ReactElement { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); const uniqueId = useUniqueId(); const inputId = `variable-select-input-${name}-${uniqueId}`; @@ -44,10 +44,10 @@ export function VariableSelectField({ ); } -function getStyles(theme: GrafanaTheme) { +function getStyles(theme: GrafanaTheme2) { return { selectContainer: css` - margin-right: ${theme.spacing.xs}; + margin-right: ${theme.spacing(0.5)}; `, }; } diff --git a/public/app/features/variables/inspect/VariablesUnknownTable.tsx b/public/app/features/variables/inspect/VariablesUnknownTable.tsx index d9ef1157f24..b1d0a95d562 100644 --- a/public/app/features/variables/inspect/VariablesUnknownTable.tsx +++ b/public/app/features/variables/inspect/VariablesUnknownTable.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import React, { ReactElement, useEffect, useState } from 'react'; import { useAsync } from 'react-use'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { CollapsableSection, HorizontalGroup, Icon, Spinner, Tooltip, useStyles, VerticalGroup } from '@grafana/ui'; +import { CollapsableSection, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2, VerticalGroup } from '@grafana/ui'; import { DashboardModel } from '../../dashboard/state'; import { VariableModel } from '../types'; @@ -23,7 +23,7 @@ export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknown const [open, setOpen] = useState(false); const [changed, setChanged] = useState(0); const [usages, setUsages] = useState([]); - const style = useStyles(getStyles); + const style = useStyles2(getStyles); useEffect(() => setChanged((prevState) => prevState + 1), [variables, dashboard]); const { loading } = useAsync(async () => { if (open && changed > 0) { @@ -74,7 +74,7 @@ export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknown } function CollapseLabel(): ReactElement { - const style = useStyles(getStyles); + const style = useStyles2(getStyles); return (
Renamed or missing variables @@ -90,7 +90,7 @@ function NoUnknowns(): ReactElement { } function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement { - const style = useStyles(getStyles); + const style = useStyles2(getStyles); return ( @@ -122,13 +122,13 @@ function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement { ); } -const getStyles = (theme: GrafanaTheme) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ container: css` - margin-top: ${theme.spacing.xl}; - padding-top: ${theme.spacing.xl}; + margin-top: ${theme.spacing(4)}; + padding-top: ${theme.spacing(4)}; `, infoIcon: css` - margin-left: ${theme.spacing.sm}; + margin-left: ${theme.spacing(1)}; `, defaultColumn: css` width: 1%; @@ -136,7 +136,7 @@ const getStyles = (theme: GrafanaTheme) => ({ firstColumn: css` width: 1%; vertical-align: top; - color: ${theme.colors.textStrong}; + color: ${theme.colors.text.maxContrast}; `, lastColumn: css` overflow: hidden; diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx index 42b51c096f0..97051f5b8e0 100644 --- a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { getTheme } from '@grafana/ui'; +import { createTheme } from '@grafana/data'; import PromQlLanguageProvider from '../language_provider'; @@ -131,7 +131,7 @@ describe('PrometheusMetricsBrowser', () => { }; const defaults: BrowserProps = { - theme: getTheme(), + theme: createTheme({ colors: { mode: 'dark' } }), onChange: () => {}, autoSelect: 0, languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider, diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx index 1e3dc95ed7b..0b2f27a9bf7 100644 --- a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import React, { ChangeEvent } from 'react'; import { FixedSizeList } from 'react-window'; -import { GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { Button, HorizontalGroup, @@ -10,8 +10,8 @@ import { Label, LoadingPlaceholder, stylesFactory, - withTheme, BrowserLabel as PromLabel, + withTheme2, } from '@grafana/ui'; import PromQlLanguageProvider from '../language_provider'; @@ -25,7 +25,7 @@ const LIST_ITEM_SIZE = 25; export interface BrowserProps { languageProvider: PromQlLanguageProvider; onChange: (selector: string) => void; - theme: GrafanaTheme; + theme: GrafanaTheme2; autoSelect?: number; hide?: () => void; lastUsedLabels: string[]; @@ -112,14 +112,14 @@ export function facetLabels( }); } -const getStyles = stylesFactory((theme: GrafanaTheme) => ({ +const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ wrapper: css` - background-color: ${theme.colors.bg2}; - padding: ${theme.spacing.sm}; + background-color: ${theme.colors.background.secondary}; + padding: ${theme.spacing(1)}; width: 100%; `, list: css` - margin-top: ${theme.spacing.sm}; + margin-top: ${theme.spacing(1)}; display: flex; flex-wrap: wrap; max-height: 200px; @@ -128,17 +128,17 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ `, section: css` & + & { - margin: ${theme.spacing.md} 0; + margin: ${theme.spacing(2)} 0; } position: relative; `, selector: css` - font-family: ${theme.typography.fontFamily.monospace}; - margin-bottom: ${theme.spacing.sm}; + font-family: ${theme.typography.fontFamilyMonospace}; + margin-bottom: ${theme.spacing(1)}; `, status: css` - padding: ${theme.spacing.xs}; - color: ${theme.colors.textSemiWeak}; + padding: ${theme.spacing(0.5)}; + color: ${theme.colors.text.secondary}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -154,30 +154,30 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ opacity: 1; `, error: css` - color: ${theme.palette.brandDanger}; + color: ${theme.colors.error.main}; `, valueList: css` - margin-right: ${theme.spacing.sm}; + margin-right: ${theme.spacing(1)}; resize: horizontal; `, valueListWrapper: css` - border-left: 1px solid ${theme.colors.border2}; - margin: ${theme.spacing.sm} 0; - padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm}; + border-left: 1px solid ${theme.colors.border.medium}; + margin: ${theme.spacing(1)} 0; + padding: ${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)}; `, valueListArea: css` display: flex; flex-wrap: wrap; - margin-top: ${theme.spacing.sm}; + margin-top: ${theme.spacing(1)}; `, valueTitle: css` - margin-left: -${theme.spacing.xs}; - margin-bottom: ${theme.spacing.sm}; + margin-left: -${theme.spacing(0.5)}; + margin-bottom: ${theme.spacing(1)}; `, validationStatus: css` - padding: ${theme.spacing.xs}; - margin-bottom: ${theme.spacing.sm}; - color: ${theme.colors.textStrong}; + padding: ${theme.spacing(0.5)}; + margin-bottom: ${theme.spacing(1)}; + color: ${theme.colors.text.maxContrast}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -656,4 +656,4 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component {} export function QuerySettings({ options, onOptionsChange }: Props) { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); return (
diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx index b5ec6b5335e..af02ec7a7e9 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import React, { LegacyRef } from 'react'; import { createTheme, Field, getDisplayProcessor } from '@grafana/data'; -import { useStyles, Tooltip } from '@grafana/ui'; +import { useStyles2, Tooltip } from '@grafana/ui'; import { TooltipData, SampleUnit } from '../types'; @@ -13,7 +13,7 @@ type Props = { }; const FlameGraphTooltip = ({ tooltipRef, tooltipData, showTooltip }: Props) => { - const styles = useStyles(getStyles); + const styles = useStyles2(getStyles); return (
diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index daa8f89b577..99e63d498b2 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -11,9 +11,9 @@ import { fromLonLat } from 'ol/proj'; import React, { Component, ReactNode } from 'react'; import { Subscription } from 'rxjs'; -import { DataHoverEvent, GrafanaTheme, PanelData, PanelProps } from '@grafana/data'; +import { DataHoverEvent, PanelData, PanelProps } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { PanelContext, PanelContextRoot, stylesFactory } from '@grafana/ui'; +import { PanelContext, PanelContextRoot } from '@grafana/ui'; import { PanelEditExitedEvent } from 'app/types/events'; import { GeomapOverlay, OverlayProps } from './GeomapOverlay'; @@ -53,7 +53,6 @@ export class GeomapPanel extends Component { globalCSS = getGlobalStyles(config.theme2); mouseWheelZoom?: MouseWheelZoom; - style = getStyles(config.theme); hoverPayload: GeomapHoverPayload = { point: {}, pageX: -1, pageY: -1 }; readonly hoverEvent = new DataHoverEvent(this.hoverPayload); @@ -383,8 +382,8 @@ export class GeomapPanel extends Component { return ( <> -
-
+
+
{ } } -const getStyles = stylesFactory((theme: GrafanaTheme) => ({ +const styles = { wrap: css` position: relative; width: 100%; @@ -410,4 +409,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({ width: 100%; height: 100%; `, -})); +}; diff --git a/public/app/plugins/panel/xychart/ManualEditor.tsx b/public/app/plugins/panel/xychart/ManualEditor.tsx index 22b00cfe7ff..e80a8f0849f 100644 --- a/public/app/plugins/panel/xychart/ManualEditor.tsx +++ b/public/app/plugins/panel/xychart/ManualEditor.tsx @@ -1,8 +1,8 @@ import { css, cx } from '@emotion/css'; import React, { useState, useEffect } from 'react'; -import { GrafanaTheme, StandardEditorProps } from '@grafana/data'; -import { Button, Field, IconButton, useStyles } from '@grafana/ui'; +import { GrafanaTheme2, StandardEditorProps } from '@grafana/data'; +import { Button, Field, IconButton, useStyles2 } from '@grafana/ui'; import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; import { LayerName } from 'app/core/components/Layers/LayerName'; import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors'; @@ -14,8 +14,8 @@ export const ManualEditor = ({ onChange, context, }: StandardEditorProps) => { - const [selected, setSelected] = useState(0); - const style = useStyles(getStyles); + const [selected, setSelected] = useState(0); + const style = useStyles2(getStyles); const onFieldChange = (val: any | undefined, index: number, field: string) => { onChange( @@ -125,34 +125,34 @@ export const ManualEditor = ({ ); }; -const getStyles = (theme: GrafanaTheme) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ marginBot: css` margin-bottom: 20px; `, row: css` - padding: ${theme.spacing.xs} ${theme.spacing.sm}; - border-radius: ${theme.border.radius.sm}; - background: ${theme.colors.bg2}; - min-height: ${theme.spacing.formInputHeight}px; + padding: ${theme.spacing(0.5, 1)}; + border-radius: ${theme.shape.borderRadius(1)}; + background: ${theme.colors.background.secondary}; + min-height: ${theme.spacing(4)}; display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; cursor: pointer; - border: 1px solid ${theme.colors.formInputBorder}; + border: 1px solid ${theme.components.input.borderColor}; &:hover { - border: 1px solid ${theme.colors.formInputBorderHover}; + border: 1px solid ${theme.components.input.borderHover}; } `, sel: css` - border: 1px solid ${theme.colors.formInputBorderActive}; + border: 1px solid ${theme.colors.primary.border}; &:hover { - border: 1px solid ${theme.colors.formInputBorderActive}; + border: 1px solid ${theme.colors.primary.border}; } `, actionIcon: css` - color: ${theme.colors.textWeak}; + color: ${theme.colors.text.secondary}; &:hover { color: ${theme.colors.text}; } From 1722000309c6e6dc36c9e4f5ef9c2e40cc6e6c65 Mon Sep 17 00:00:00 2001 From: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Date: Thu, 3 Nov 2022 13:42:23 -0500 Subject: [PATCH 023/926] fixes typo (#58159) --- docs/sources/whatsnew/whats-new-in-v9-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/whatsnew/whats-new-in-v9-0.md b/docs/sources/whatsnew/whats-new-in-v9-0.md index 04698af2f91..81fde0d2a6a 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-0.md +++ b/docs/sources/whatsnew/whats-new-in-v9-0.md @@ -41,7 +41,7 @@ All functions, aggregations and binary operations are added via the + Operation ### Range vector -The query builder will automatically mange and add the range selector. It will be shown as a parameter to the operations that require a range vector (rate, delta, increase, etc). +The query builder will automatically manage and add the range selector. It will be shown as a parameter to the operations that require a range vector (rate, delta, increase, etc). ### Binary operations From 0367f61bb3701dcfaceb2800a16e71753a31a29b Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Thu, 3 Nov 2022 21:44:37 +0300 Subject: [PATCH 024/926] Share azureauth between prometheus clients (#58122) * Move azureauth to upper package * Refactor http transport options --- pkg/tsdb/prometheus/{buffered => }/azureauth/azure.go | 0 .../prometheus/{buffered => }/azureauth/azure_test.go | 0 pkg/tsdb/prometheus/buffered/time_series_query.go | 9 +++++---- .../{buffered/client.go => client/transport.go} | 6 +++--- .../client_test.go => client/transport_test.go} | 2 +- pkg/tsdb/prometheus/prometheus.go | 3 ++- pkg/tsdb/prometheus/querydata/request_test.go | 4 ++-- 7 files changed, 13 insertions(+), 11 deletions(-) rename pkg/tsdb/prometheus/{buffered => }/azureauth/azure.go (100%) rename pkg/tsdb/prometheus/{buffered => }/azureauth/azure_test.go (100%) rename pkg/tsdb/prometheus/{buffered/client.go => client/transport.go} (92%) rename pkg/tsdb/prometheus/{buffered/client_test.go => client/transport_test.go} (98%) diff --git a/pkg/tsdb/prometheus/buffered/azureauth/azure.go b/pkg/tsdb/prometheus/azureauth/azure.go similarity index 100% rename from pkg/tsdb/prometheus/buffered/azureauth/azure.go rename to pkg/tsdb/prometheus/azureauth/azure.go diff --git a/pkg/tsdb/prometheus/buffered/azureauth/azure_test.go b/pkg/tsdb/prometheus/azureauth/azure_test.go similarity index 100% rename from pkg/tsdb/prometheus/buffered/azureauth/azure_test.go rename to pkg/tsdb/prometheus/azureauth/azure_test.go diff --git a/pkg/tsdb/prometheus/buffered/time_series_query.go b/pkg/tsdb/prometheus/buffered/time_series_query.go index 272f776deb4..9e11c85cd92 100644 --- a/pkg/tsdb/prometheus/buffered/time_series_query.go +++ b/pkg/tsdb/prometheus/buffered/time_series_query.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" sdkHTTPClient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/tsdb/prometheus/client" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" "github.com/prometheus/common/model" "go.opentelemetry.io/otel/attribute" @@ -68,7 +69,7 @@ type Buffered struct { // New creates and object capable of executing and parsing a Prometheus queries. It's "buffered" because there is // another implementation capable of streaming parse the response. func New(roundTripper http.RoundTripper, tracer tracing.Tracer, settings backend.DataSourceInstanceSettings, plog log.Logger) (*Buffered, error) { - promClient, err := CreateClient(roundTripper, settings.URL) + promClient, err := client.CreateAPIClient(roundTripper, settings.URL) if err != nil { return nil, fmt.Errorf("error creating prom client: %v", err) } @@ -232,7 +233,7 @@ func (b *Buffered) parseTimeSeriesQuery(req *backend.QueryDataRequest) ([]*Prome if err != nil { return nil, fmt.Errorf("error unmarshaling query model: %v", err) } - //Final interval value + // Final interval value interval, err := calculatePrometheusInterval(model, b.TimeInterval, query, b.intervalCalculator) if err != nil { return nil, fmt.Errorf("error calculating interval: %v", err) @@ -301,7 +302,7 @@ func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *P func calculatePrometheusInterval(model *QueryModel, timeInterval string, query backend.DataQuery, intervalCalculator intervalv2.Calculator) (time.Duration, error) { queryInterval := model.Interval - //If we are using variable for interval/step, we will replace it with calculated interval + // If we are using variable for interval/step, we will replace it with calculated interval if isVariableInterval(queryInterval) { queryInterval = "" } @@ -656,7 +657,7 @@ func isVariableInterval(interval string) bool { if interval == varInterval || interval == varIntervalMs || interval == varRateInterval { return true } - //Repetitive code, we should have functionality to unify these + // Repetitive code, we should have functionality to unify these if interval == varIntervalAlt || interval == varIntervalMsAlt || interval == varRateIntervalAlt { return true } diff --git a/pkg/tsdb/prometheus/buffered/client.go b/pkg/tsdb/prometheus/client/transport.go similarity index 92% rename from pkg/tsdb/prometheus/buffered/client.go rename to pkg/tsdb/prometheus/client/transport.go index 79a0e619ba3..1d79dd98aca 100644 --- a/pkg/tsdb/prometheus/buffered/client.go +++ b/pkg/tsdb/prometheus/client/transport.go @@ -1,4 +1,4 @@ -package buffered +package client import ( "fmt" @@ -9,7 +9,7 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/prometheus/buffered/azureauth" + "github.com/grafana/grafana/pkg/tsdb/prometheus/azureauth" "github.com/grafana/grafana/pkg/tsdb/prometheus/middleware" "github.com/grafana/grafana/pkg/tsdb/prometheus/utils" "github.com/grafana/grafana/pkg/util/maputil" @@ -49,7 +49,7 @@ func CreateTransportOptions(settings backend.DataSourceInstanceSettings, cfg *se return &opts, nil } -func CreateClient(roundTripper http.RoundTripper, url string) (apiv1.API, error) { +func CreateAPIClient(roundTripper http.RoundTripper, url string) (apiv1.API, error) { cfg := api.Config{ Address: url, RoundTripper: roundTripper, diff --git a/pkg/tsdb/prometheus/buffered/client_test.go b/pkg/tsdb/prometheus/client/transport_test.go similarity index 98% rename from pkg/tsdb/prometheus/buffered/client_test.go rename to pkg/tsdb/prometheus/client/transport_test.go index 105803503be..945e0ada0dc 100644 --- a/pkg/tsdb/prometheus/buffered/client_test.go +++ b/pkg/tsdb/prometheus/client/transport_test.go @@ -1,4 +1,4 @@ -package buffered +package client import ( "testing" diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 1851c856f4e..43b12d31ddb 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana/pkg/tsdb/prometheus/client" "github.com/patrickmn/go-cache" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" "github.com/yudai/gojsondiff" @@ -53,7 +54,7 @@ func ProvideService(httpClientProvider httpclient.Provider, cfg *setting.Cfg, fe func newInstanceSettings(httpClientProvider httpclient.Provider, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) datasource.InstanceFactoryFunc { return func(settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { // Creates a http roundTripper. Probably should be used for both buffered and streaming/querydata instances. - opts, err := buffered.CreateTransportOptions(settings, cfg, plog) + opts, err := client.CreateTransportOptions(settings, cfg, plog) if err != nil { return nil, fmt.Errorf("error creating transport options: %v", err) } diff --git a/pkg/tsdb/prometheus/querydata/request_test.go b/pkg/tsdb/prometheus/querydata/request_test.go index 426ccfa73e5..abc34dc608d 100644 --- a/pkg/tsdb/prometheus/querydata/request_test.go +++ b/pkg/tsdb/prometheus/querydata/request_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/tsdb/prometheus/client" apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" p "github.com/prometheus/common/model" "github.com/stretchr/testify/require" @@ -21,7 +22,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/prometheus/buffered" "github.com/grafana/grafana/pkg/tsdb/prometheus/models" "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata" ) @@ -417,7 +417,7 @@ func setup(wideFrames bool) (*testContext, error) { features := &fakeFeatureToggles{flags: map[string]bool{"prometheusStreamingJSONParser": true, "prometheusWideSeries": wideFrames}} - opts, err := buffered.CreateTransportOptions(settings, &setting.Cfg{}, &logtest.Fake{}) + opts, err := client.CreateTransportOptions(settings, &setting.Cfg{}, &logtest.Fake{}) if err != nil { return nil, err } From 6fcc5b42c0a5bdd83d8362c0321a36fa7b8ea3fb Mon Sep 17 00:00:00 2001 From: Jeff Levin Date: Thu, 3 Nov 2022 11:30:12 -0800 Subject: [PATCH 025/926] publicdashboards: split create/update api paths (#57940) This PR splits the create and update paths for public dashboards and includes assorted refactors toward a proper REST API. Additionally, we removed the concept of a "public dashboard config" in favor of "public dashboard" Co-authored-by: juanicabanas Co-authored-by: Ezequiel Victorero --- .../dashboard-public-create.spec.ts | 10 +- .../dashboard-public-templating.spec.ts | 2 - pkg/api/dashboard.go | 21 +- pkg/api/dtos/dashboard.go | 1 + .../dashboards/database/database_test.go | 4 +- pkg/services/publicdashboards/api/api.go | 96 ++- pkg/services/publicdashboards/api/api_test.go | 576 +++++++++++------- .../publicdashboards/api/query_test.go | 2 +- .../publicdashboards/database/database.go | 140 ++--- .../database/database_test.go | 67 +- .../internal/tokens/tokens.go | 7 + .../internal/tokens/tokens_test.go | 16 + .../public_dashboard_service_mock.go | 27 +- .../public_dashboard_store_mock.go | 52 +- .../publicdashboards/publicdashboard.go | 7 +- .../publicdashboards/service/query_test.go | 6 +- .../publicdashboards/service/service.go | 167 ++--- .../publicdashboards/service/service_test.go | 291 +++++---- .../publicdashboards/validation/validation.go | 2 +- .../validation/validation_test.go | 6 +- .../dashboard/api/publicDashboardApi.ts | 42 +- .../SharePublicDashboard.test.tsx | 31 +- .../SharePublicDashboard.tsx | 34 +- public/app/types/dashboard.ts | 1 + 24 files changed, 996 insertions(+), 612 deletions(-) diff --git a/e2e/dashboards-suite/dashboard-public-create.spec.ts b/e2e/dashboards-suite/dashboard-public-create.spec.ts index c5f82a5ffb9..745fcc4655a 100644 --- a/e2e/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-create.spec.ts @@ -8,7 +8,7 @@ e2e.scenario({ skipScenario: false, scenario: () => { // Opening a dashboard without template variables - e2e().intercept('/api/ds/query').as('query'); + e2e().intercept('POST', '/api/ds/query').as('query'); e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' }); e2e().wait('@query'); @@ -16,9 +16,7 @@ e2e.scenario({ e2e.pages.ShareDashboardModal.shareButton().click(); // Select public dashboards tab - e2e().intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); - e2e().wait('@query-public-dashboard'); // Saving button should be disabled e2e.pages.ShareDashboardModal.PublicDashboard.SaveConfigButton().should('be.disabled'); @@ -57,7 +55,7 @@ e2e.scenario({ skipScenario: false, scenario: () => { // Opening a dashboard without template variables - e2e().intercept('/api/ds/query').as('query'); + e2e().intercept('POST', '/api/ds/query').as('query'); e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' }); e2e().wait('@query'); @@ -125,9 +123,9 @@ e2e.scenario({ e2e.pages.ShareDashboardModal.PublicDashboard.EnableSwitch().should('be.enabled').click({ force: true }); // Save public dashboard - e2e().intercept('POST', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('save'); + e2e().intercept('PUT', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards/*').as('update'); e2e.pages.ShareDashboardModal.PublicDashboard.SaveConfigButton().click(); - e2e().wait('@save'); + e2e().wait('@update'); // Url should be hidden e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('not.exist'); diff --git a/e2e/dashboards-suite/dashboard-public-templating.spec.ts b/e2e/dashboards-suite/dashboard-public-templating.spec.ts index 1262a564800..808cff56902 100644 --- a/e2e/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-templating.spec.ts @@ -14,9 +14,7 @@ e2e.scenario({ e2e.pages.ShareDashboardModal.shareButton().click(); // Select public dashboards tab - e2e().intercept('GET', '/api/dashboards/uid/HYaGDGIMk/public-dashboards').as('query-public-config'); e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); - e2e().wait('@query-public-config'); // Warning Alert dashboard cannot be made public because it has template variables e2e.pages.ShareDashboardModal.PublicDashboard.TemplateVariablesWarningAlert().should('be.visible'); diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 479160aae85..2f3f335d389 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" + publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -100,14 +101,23 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { } var ( - hasPublicDashboard bool - err error + hasPublicDashboard = false + publicDashboardEnabled = false + err error ) + + // If public dashboards is enabled and we have a public dashboard, update meta + // values if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { - hasPublicDashboard, err = hs.PublicDashboardsApi.PublicDashboardService.ExistsEnabledByDashboardUid(c.Req.Context(), dash.Uid) - if err != nil { + publicDashboard, err := hs.PublicDashboardsApi.PublicDashboardService.FindByDashboardUid(c.Req.Context(), c.OrgID, dash.Uid) + if err != nil && !errors.Is(err, publicdashboardModels.ErrPublicDashboardNotFound) { return response.Error(500, "Error while retrieving public dashboards", err) } + + if publicDashboard != nil { + hasPublicDashboard = true + publicDashboardEnabled = publicDashboard.IsEnabled + } } // When dash contains only keys id, uid that means dashboard data is not valid and json decode failed. @@ -172,7 +182,8 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { Url: dash.GetUrl(), FolderTitle: "General", AnnotationsPermissions: annotationPermissions, - PublicDashboardEnabled: hasPublicDashboard, + PublicDashboardEnabled: publicDashboardEnabled, + HasPublicDashboard: hasPublicDashboard, } // lookup folder title diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 3918374cf7f..0d13051f5be 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -32,6 +32,7 @@ type DashboardMeta struct { Provisioned bool `json:"provisioned"` ProvisionedExternalId string `json:"provisionedExternalId"` AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` + HasPublicDashboard bool `json:"hasPublicDashboard"` PublicDashboardAccessToken string `json:"publicDashboardAccessToken"` PublicDashboardUID string `json:"publicDashboardUid"` PublicDashboardEnabled bool `json:"publicDashboardEnabled"` diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 39222b6414e..5163e0d8d90 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -257,7 +257,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { AccessToken: "an-access-token", }, } - err := publicDashboardStore.Save(context.Background(), cmd) + _, err := publicDashboardStore.Create(context.Background(), cmd) require.NoError(t, err) pubdashConfig, _ := publicDashboardStore.FindByAccessToken(context.Background(), "an-access-token") require.NotNil(t, pubdashConfig) @@ -292,7 +292,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { AccessToken: "an-access-token", }, } - err := publicDashboardStore.Save(context.Background(), cmd) + _, err := publicDashboardStore.Create(context.Background(), cmd) require.NoError(t, err) pubdashConfig, _ := publicDashboardStore.FindByAccessToken(context.Background(), "an-access-token") require.NotNil(t, pubdashConfig) diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index d0766c95634..61e867ad287 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -15,8 +15,8 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards" + "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -73,10 +73,15 @@ func (api *Api) RegisterAPIEndpoints() { auth(middleware.ReqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, uidScope)), routing.Wrap(api.GetPublicDashboard)) - // Create/Update Public Dashboard + // Create Public Dashboard api.RouteRegister.Post("/api/dashboards/uid/:dashboardUid/public-dashboards", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(dashboards.ActionDashboardsPublicWrite, uidScope)), - routing.Wrap(api.SavePublicDashboard)) + routing.Wrap(api.CreatePublicDashboard)) + + // Update Public Dashboard + api.RouteRegister.Put("/api/dashboards/uid/:dashboardUid/public-dashboards/:uid", + auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(dashboards.ActionDashboardsPublicWrite, uidScope)), + routing.Wrap(api.UpdatePublicDashboard)) // Delete Public dashboard api.RouteRegister.Delete("/api/dashboards/uid/:dashboardUid/public-dashboards/:uid", @@ -94,59 +99,103 @@ func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, resp) } -// GetPublicDashboard Gets public dashboard configuration for dashboard -// GET /api/dashboards/uid/:uid/public-config +// GetPublicDashboard Gets public dashboard for dashboard +// GET /api/dashboards/uid/:uid/public-dashboards func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] - if dashboardUid == "" || !util.IsValidShortUID(dashboardUid) { + if !tokens.IsValidShortUID(dashboardUid) { api.handleError(c.Req.Context(), http.StatusBadRequest, "GetPublicDashboard: no valid dashboardUid", dashboards.ErrDashboardIdentifierNotSet) } - pdc, err := api.PublicDashboardService.FindByDashboardUid(c.Req.Context(), c.OrgID, web.Params(c.Req)[":dashboardUid"]) + pd, err := api.PublicDashboardService.FindByDashboardUid(c.Req.Context(), c.OrgID, web.Params(c.Req)[":dashboardUid"]) + if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "GetPublicDashboardConfig: failed to get public dashboard config", err) + return api.handleError(c.Req.Context(), http.StatusInternalServerError, "GetPublicDashboard: failed to get public dashboard ", err) } - return response.JSON(http.StatusOK, pdc) + + if pd == nil { + return api.handleError(c.Req.Context(), http.StatusNotFound, "GetPublicDashboard: public dashboard not found", ErrPublicDashboardNotFound) + } + + return response.JSON(http.StatusOK, pd) } -// SavePublicDashboard Sets public dashboard configuration for dashboard -// POST /api/dashboards/uid/:uid/public-config -func (api *Api) SavePublicDashboard(c *models.ReqContext) response.Response { +// CreatePublicDashboard Sets public dashboard for dashboard +// POST /api/dashboards/uid/:uid/public-dashboards +func (api *Api) CreatePublicDashboard(c *models.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] - if dashboardUid == "" || !util.IsValidShortUID(dashboardUid) { - api.handleError(c.Req.Context(), http.StatusBadRequest, "SavePublicDashboard: invalid dashboardUid", dashboards.ErrDashboardIdentifierNotSet) + if !tokens.IsValidShortUID(dashboardUid) { + return api.handleError(c.Req.Context(), http.StatusBadRequest, "CreatePublicDashboard: invalid dashboardUid", dashboards.ErrDashboardIdentifierInvalid) } - pubdash := &PublicDashboard{} - if err := web.Bind(c.Req, pubdash); err != nil { - return response.Error(http.StatusBadRequest, "SavePublicDashboard: bad request data", err) + pd := &PublicDashboard{} + if err := web.Bind(c.Req, pd); err != nil { + return api.handleError(c.Req.Context(), http.StatusBadRequest, "CreatePublicDashboard: bad request data", err) } // Always set the orgID and userID from the session - pubdash.OrgId = c.OrgID + pd.OrgId = c.OrgID dto := SavePublicDashboardDTO{ UserId: c.UserID, OrgId: c.OrgID, DashboardUid: dashboardUid, - PublicDashboard: pubdash, + PublicDashboard: pd, + } + + //Create the public dashboard + pd, err := api.PublicDashboardService.Create(c.Req.Context(), c.SignedInUser, &dto) + if err != nil { + return api.handleError(c.Req.Context(), http.StatusInternalServerError, "CreatePublicDashboard: failed to create public dashboard", err) + } + + return response.JSON(http.StatusOK, pd) +} + +// UpdatePublicDashboard Sets public dashboard for dashboard +// PUT /api/dashboards/uid/:uid/public-dashboards +func (api *Api) UpdatePublicDashboard(c *models.ReqContext) response.Response { + // exit if we don't have a valid dashboardUid + dashboardUid := web.Params(c.Req)[":dashboardUid"] + if !tokens.IsValidShortUID(dashboardUid) { + return api.handleError(c.Req.Context(), http.StatusBadRequest, "UpdatePublicDashboard: invalid dashboardUid", dashboards.ErrDashboardIdentifierInvalid) + } + + uid := web.Params(c.Req)[":uid"] + if !tokens.IsValidShortUID(uid) { + return api.handleError(c.Req.Context(), http.StatusBadRequest, "UpdatePublicDashboard: invalid public dashboard uid", ErrPublicDashboardIdentifierNotSet) + } + + pd := &PublicDashboard{} + if err := web.Bind(c.Req, pd); err != nil { + return api.handleError(c.Req.Context(), http.StatusBadRequest, "UpdatePublicDashboard: bad request data", err) + } + + // Always set the orgID and userID from the session + pd.OrgId = c.OrgID + pd.Uid = uid + dto := SavePublicDashboardDTO{ + UserId: c.UserID, + OrgId: c.OrgID, + DashboardUid: dashboardUid, + PublicDashboard: pd, } // Save the public dashboard - pubdash, err := api.PublicDashboardService.Save(c.Req.Context(), c.SignedInUser, &dto) + pd, err := api.PublicDashboardService.Update(c.Req.Context(), c.SignedInUser, &dto) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "SavePublicDashboardConfig: failed to save public dashboard configuration", err) + return api.handleError(c.Req.Context(), http.StatusInternalServerError, "UpdatePublicDashboard: failed to update public dashboard", err) } - return response.JSON(http.StatusOK, pubdash) + return response.JSON(http.StatusOK, pd) } // Delete a public dashboard // DELETE /api/dashboards/uid/:dashboardUid/public-dashboards/:uid func (api *Api) DeletePublicDashboard(c *models.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] - if uid == "" || !util.IsValidShortUID(uid) { + if !tokens.IsValidShortUID(uid) { return api.handleError(c.Req.Context(), http.StatusBadRequest, "DeletePublicDashboard: invalid dashboard uid", dashboards.ErrDashboardIdentifierNotSet) } @@ -171,7 +220,6 @@ func (api *Api) handleError(ctx context.Context, code int, message string, err e return response.Error(publicDashboardErr.StatusCode, publicDashboardErr.Error(), publicDashboardErr) } - // handle dashboard errors as well var dashboardErr dashboards.DashboardErr if ok := errors.As(err, &dashboardErr); ok { return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), dashboardErr) diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index faffc2071f2..25b279cf978 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -57,10 +57,20 @@ func TestAPIFeatureFlag(t *testing.T) { Path: "/api/dashboards/uid/abc123/public-dashboards", }, { - Name: "API: Save Public Dashboard", + Name: "API: Create Public Dashboard", Method: http.MethodPost, Path: "/api/dashboards/uid/abc123/public-dashboards", }, + { + Name: "API: Update Public Dashboard", + Method: http.MethodPut, + Path: "/api/dashboards/uid/abc123/public-dashboards", + }, + { + Name: "API: Delete Public Dashboard", + Method: http.MethodDelete, + Path: "/api/dashboards/uid/:dashboardUid/public-dashboards/:uid", + }, } for _, test := range testCases { @@ -148,6 +158,354 @@ func TestAPIListPublicDashboard(t *testing.T) { } } +func TestAPIGetPublicDashboard(t *testing.T) { + pubdash := &PublicDashboard{IsEnabled: true} + + testCases := []struct { + Name string + DashboardUid string + ExpectedHttpResponse int + PublicDashboardResult *PublicDashboard + PublicDashboardErr error + User *user.SignedInUser + AccessControlEnabled bool + ShouldCallService bool + }{ + { + Name: "retrieves public dashboard when dashboard is found", + DashboardUid: "1", + ExpectedHttpResponse: http.StatusOK, + PublicDashboardResult: pubdash, + PublicDashboardErr: nil, + User: userViewer, + AccessControlEnabled: false, + ShouldCallService: true, + }, + { + Name: "returns 404 when dashboard not found", + DashboardUid: "77777", + ExpectedHttpResponse: http.StatusNotFound, + PublicDashboardResult: nil, + PublicDashboardErr: dashboards.ErrDashboardNotFound, + User: userViewer, + AccessControlEnabled: false, + ShouldCallService: true, + }, + { + Name: "returns 500 when internal server error", + DashboardUid: "1", + ExpectedHttpResponse: http.StatusInternalServerError, + PublicDashboardResult: nil, + PublicDashboardErr: errors.New("database broken"), + User: userViewer, + AccessControlEnabled: false, + ShouldCallService: true, + }, + { + Name: "retrieves public dashboard when dashboard is found RBAC on", + DashboardUid: "1", + ExpectedHttpResponse: http.StatusOK, + PublicDashboardResult: pubdash, + PublicDashboardErr: nil, + User: userViewerRBAC, + AccessControlEnabled: true, + ShouldCallService: true, + }, + { + Name: "returns 403 when no permissions RBAC on", + ExpectedHttpResponse: http.StatusForbidden, + PublicDashboardResult: pubdash, + PublicDashboardErr: nil, + User: userViewer, + AccessControlEnabled: true, + ShouldCallService: false, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + + if test.ShouldCallService { + service.On("FindByDashboardUid", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). + Return(test.PublicDashboardResult, test.PublicDashboardErr) + } + + cfg := setting.NewCfg() + cfg.RBACEnabled = test.AccessControlEnabled + + testServer := setupTestServer( + t, + cfg, + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), + service, + nil, + test.User, + ) + + response := callAPI( + testServer, + http.MethodGet, + "/api/dashboards/uid/1/public-dashboards", + nil, + t, + ) + + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + if response.Code == http.StatusOK { + var pdcResp PublicDashboard + err := json.Unmarshal(response.Body.Bytes(), &pdcResp) + require.NoError(t, err) + assert.Equal(t, test.PublicDashboardResult, &pdcResp) + } + }) + } +} + +func TestApiCreatePublicDashboard(t *testing.T) { + testCases := []struct { + Name string + DashboardUid string + publicDashboard *PublicDashboard + ExpectedHttpResponse int + SaveDashboardErr error + User *user.SignedInUser + AccessControlEnabled bool + ShouldCallService bool + }{ + { + Name: "returns 200 when update persists", + DashboardUid: "1", + publicDashboard: &PublicDashboard{IsEnabled: true}, + ExpectedHttpResponse: http.StatusOK, + SaveDashboardErr: nil, + User: userAdmin, + AccessControlEnabled: false, + ShouldCallService: true, + }, + { + Name: "returns 500 when not persisted", + ExpectedHttpResponse: http.StatusInternalServerError, + publicDashboard: &PublicDashboard{}, + SaveDashboardErr: errors.New("backend failed to save"), + User: userAdmin, + AccessControlEnabled: false, + ShouldCallService: true, + }, + { + Name: "returns 404 when dashboard not found", + ExpectedHttpResponse: http.StatusNotFound, + publicDashboard: &PublicDashboard{}, + SaveDashboardErr: dashboards.ErrDashboardNotFound, + User: userAdmin, + AccessControlEnabled: false, + ShouldCallService: true, + }, + { + Name: "returns 200 when update persists RBAC on", + DashboardUid: "1", + publicDashboard: &PublicDashboard{IsEnabled: true}, + ExpectedHttpResponse: http.StatusOK, + SaveDashboardErr: nil, + User: userAdminRBAC, + AccessControlEnabled: true, + ShouldCallService: true, + }, + { + Name: "returns 403 when no permissions", + ExpectedHttpResponse: http.StatusForbidden, + publicDashboard: &PublicDashboard{IsEnabled: true}, + SaveDashboardErr: nil, + User: userViewer, + AccessControlEnabled: false, + ShouldCallService: false, + }, + { + Name: "returns 403 when no permissions RBAC on", + ExpectedHttpResponse: http.StatusForbidden, + publicDashboard: &PublicDashboard{IsEnabled: true}, + SaveDashboardErr: nil, + User: userAdmin, + AccessControlEnabled: true, + ShouldCallService: false, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + + // this is to avoid AssertExpectations fail at t.Cleanup when the middleware returns before calling the service + if test.ShouldCallService { + service.On("Create", mock.Anything, mock.Anything, mock.AnythingOfType("*models.SavePublicDashboardDTO")). + Return(&PublicDashboard{IsEnabled: true}, test.SaveDashboardErr) + } + + cfg := setting.NewCfg() + cfg.RBACEnabled = test.AccessControlEnabled + + testServer := setupTestServer( + t, + cfg, + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), + service, + nil, + test.User, + ) + + response := callAPI( + testServer, + http.MethodPost, + "/api/dashboards/uid/1/public-dashboards", + strings.NewReader(`{ "isPublic": true }`), + t, + ) + + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + //check the result if it's a 200 + if response.Code == http.StatusOK { + val, err := json.Marshal(test.publicDashboard) + require.NoError(t, err) + assert.Equal(t, string(val), response.Body.String()) + } + }) + } +} + +func TestAPIUpdatePublicDashboard(t *testing.T) { + dashboardUid := "abc1234" + publicDashboardUid := "1234asdfasdf" + + adminUser := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {dashboards.ScopeDashboardsAll}}}} + + userEditorPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {fmt.Sprintf("dashboards:uid:%s", dashboardUid)}}}} + + userEditorAnotherPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {"another-uid"}}}} + + testCases := []struct { + Name string + User *user.SignedInUser + DashboardUid string + PublicDashboardUid string + PublicDashboardRes *PublicDashboard + PublicDashboardErr error + ExpectedHttpResponse int + ShouldCallService bool + }{ + { + Name: "Invalid dashboardUid", + User: adminUser, + DashboardUid: "", + PublicDashboardUid: "", + PublicDashboardRes: nil, + PublicDashboardErr: dashboards.ErrDashboardIdentifierInvalid, + ExpectedHttpResponse: http.StatusNotFound, + ShouldCallService: false, + }, + { + Name: "Invalid public dashboard uid", + User: adminUser, + DashboardUid: dashboardUid, + PublicDashboardUid: "", + PublicDashboardRes: nil, + PublicDashboardErr: ErrPublicDashboardNotFound, + ExpectedHttpResponse: http.StatusNotFound, + ShouldCallService: false, + }, + { + Name: "Service Error", + User: adminUser, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + PublicDashboardRes: nil, + PublicDashboardErr: dashboards.ErrDashboardNotFound, + ExpectedHttpResponse: http.StatusNotFound, + ShouldCallService: true, + }, + { + Name: "Success", + User: adminUser, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + PublicDashboardRes: &PublicDashboard{Uid: "success"}, + PublicDashboardErr: nil, + ExpectedHttpResponse: http.StatusOK, + ShouldCallService: true, + }, + + // permissions + { + Name: "User can update this public dashboard", + User: userEditorPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + PublicDashboardRes: &PublicDashboard{Uid: "success"}, + PublicDashboardErr: nil, + ExpectedHttpResponse: http.StatusOK, + ShouldCallService: true, + }, + { + Name: "User has permissions on another dashboard", + User: userEditorAnotherPublicDashboard, + PublicDashboardUid: publicDashboardUid, + ExpectedHttpResponse: http.StatusForbidden, + ShouldCallService: false, + }, + { + Name: "Viewer cannot update any dashboard", + User: userViewer, + PublicDashboardUid: publicDashboardUid, + ExpectedHttpResponse: http.StatusForbidden, + ShouldCallService: false, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + + if test.ShouldCallService { + service.On("Update", mock.Anything, mock.Anything, mock.Anything). + Return(test.PublicDashboardRes, test.PublicDashboardErr) + } + + cfg := setting.NewCfg() + features := featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards) + testServer := setupTestServer(t, cfg, features, service, nil, test.User) + url := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", test.DashboardUid, test.PublicDashboardUid) + body := strings.NewReader(fmt.Sprintf(`{ "uid": "%s"}`, test.PublicDashboardUid)) + + response := callAPI(testServer, http.MethodPut, url, body, t) + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + // check whether service called + if !test.ShouldCallService { + service.AssertNotCalled(t, "Update") + } + + fmt.Println(response.Body.String()) + + // check response + if response.Code == http.StatusOK { + val, err := json.Marshal(test.PublicDashboardRes) + require.NoError(t, err) + assert.Equal(t, string(val), response.Body.String()) + + // verify 4XXs except 403 && 404 + } else if test.ExpectedHttpResponse > 200 && + test.ExpectedHttpResponse != 403 && + test.ExpectedHttpResponse != 404 { + var errResp JsonErrResponse + err := json.Unmarshal(response.Body.Bytes(), &errResp) + require.NoError(t, err) + assert.Equal(t, test.PublicDashboardErr.Error(), errResp.Error) + } + }) + } +} + func TestAPIDeletePublicDashboard(t *testing.T) { dashboardUid := "abc1234" publicDashboardUid := "1234asdfasdf" @@ -275,219 +633,3 @@ func TestAPIDeletePublicDashboard(t *testing.T) { }) } } - -func TestAPIGetPublicDashboard(t *testing.T) { - pubdash := &PublicDashboard{IsEnabled: true} - - testCases := []struct { - Name string - DashboardUid string - ExpectedHttpResponse int - PublicDashboardResult *PublicDashboard - PublicDashboardErr error - User *user.SignedInUser - AccessControlEnabled bool - ShouldCallService bool - }{ - { - Name: "retrieves public dashboard when dashboard is found", - DashboardUid: "1", - ExpectedHttpResponse: http.StatusOK, - PublicDashboardResult: pubdash, - PublicDashboardErr: nil, - User: userViewer, - AccessControlEnabled: false, - ShouldCallService: true, - }, - { - Name: "returns 404 when dashboard not found", - DashboardUid: "77777", - ExpectedHttpResponse: http.StatusNotFound, - PublicDashboardResult: nil, - PublicDashboardErr: dashboards.ErrDashboardNotFound, - User: userViewer, - AccessControlEnabled: false, - ShouldCallService: true, - }, - { - Name: "returns 500 when internal server error", - DashboardUid: "1", - ExpectedHttpResponse: http.StatusInternalServerError, - PublicDashboardResult: nil, - PublicDashboardErr: errors.New("database broken"), - User: userViewer, - AccessControlEnabled: false, - ShouldCallService: true, - }, - { - Name: "retrieves public dashboard when dashboard is found RBAC on", - DashboardUid: "1", - ExpectedHttpResponse: http.StatusOK, - PublicDashboardResult: pubdash, - PublicDashboardErr: nil, - User: userViewerRBAC, - AccessControlEnabled: true, - ShouldCallService: true, - }, - { - Name: "returns 403 when no permissions RBAC on", - ExpectedHttpResponse: http.StatusForbidden, - PublicDashboardResult: pubdash, - PublicDashboardErr: nil, - User: userViewer, - AccessControlEnabled: true, - ShouldCallService: false, - }, - } - - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - service := publicdashboards.NewFakePublicDashboardService(t) - - if test.ShouldCallService { - service.On("FindByDashboardUid", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). - Return(test.PublicDashboardResult, test.PublicDashboardErr) - } - - cfg := setting.NewCfg() - cfg.RBACEnabled = test.AccessControlEnabled - - testServer := setupTestServer( - t, - cfg, - featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), - service, - nil, - test.User, - ) - - response := callAPI( - testServer, - http.MethodGet, - "/api/dashboards/uid/1/public-dashboards", - nil, - t, - ) - - assert.Equal(t, test.ExpectedHttpResponse, response.Code) - - if response.Code == http.StatusOK { - var pdcResp PublicDashboard - err := json.Unmarshal(response.Body.Bytes(), &pdcResp) - require.NoError(t, err) - assert.Equal(t, test.PublicDashboardResult, &pdcResp) - } - }) - } -} - -func TestApiSavePublicDashboard(t *testing.T) { - testCases := []struct { - Name string - DashboardUid string - publicDashboard *PublicDashboard - ExpectedHttpResponse int - SaveDashboardErr error - User *user.SignedInUser - AccessControlEnabled bool - ShouldCallService bool - }{ - { - Name: "returns 200 when update persists", - DashboardUid: "1", - publicDashboard: &PublicDashboard{IsEnabled: true}, - ExpectedHttpResponse: http.StatusOK, - SaveDashboardErr: nil, - User: userAdmin, - AccessControlEnabled: false, - ShouldCallService: true, - }, - { - Name: "returns 500 when not persisted", - ExpectedHttpResponse: http.StatusInternalServerError, - publicDashboard: &PublicDashboard{}, - SaveDashboardErr: errors.New("backend failed to save"), - User: userAdmin, - AccessControlEnabled: false, - ShouldCallService: true, - }, - { - Name: "returns 404 when dashboard not found", - ExpectedHttpResponse: http.StatusNotFound, - publicDashboard: &PublicDashboard{}, - SaveDashboardErr: dashboards.ErrDashboardNotFound, - User: userAdmin, - AccessControlEnabled: false, - ShouldCallService: true, - }, - { - Name: "returns 200 when update persists RBAC on", - DashboardUid: "1", - publicDashboard: &PublicDashboard{IsEnabled: true}, - ExpectedHttpResponse: http.StatusOK, - SaveDashboardErr: nil, - User: userAdminRBAC, - AccessControlEnabled: true, - ShouldCallService: true, - }, - { - Name: "returns 403 when no permissions", - ExpectedHttpResponse: http.StatusForbidden, - publicDashboard: &PublicDashboard{IsEnabled: true}, - SaveDashboardErr: nil, - User: userViewer, - AccessControlEnabled: false, - ShouldCallService: false, - }, - { - Name: "returns 403 when no permissions RBAC on", - ExpectedHttpResponse: http.StatusForbidden, - publicDashboard: &PublicDashboard{IsEnabled: true}, - SaveDashboardErr: nil, - User: userAdmin, - AccessControlEnabled: true, - ShouldCallService: false, - }, - } - - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - service := publicdashboards.NewFakePublicDashboardService(t) - - // this is to avoid AssertExpectations fail at t.Cleanup when the middleware returns before calling the service - if test.ShouldCallService { - service.On("Save", mock.Anything, mock.Anything, mock.AnythingOfType("*models.SavePublicDashboardDTO")). - Return(&PublicDashboard{IsEnabled: true}, test.SaveDashboardErr) - } - - cfg := setting.NewCfg() - cfg.RBACEnabled = test.AccessControlEnabled - - testServer := setupTestServer( - t, - cfg, - featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), - service, - nil, - test.User, - ) - - response := callAPI( - testServer, - http.MethodPost, - "/api/dashboards/uid/1/public-dashboards", - strings.NewReader(`{ "isPublic": true }`), - t, - ) - - assert.Equal(t, test.ExpectedHttpResponse, response.Code) - - //check the result if it's a 200 - if response.Code == http.StatusOK { - val, err := json.Marshal(test.publicDashboard) - require.NoError(t, err) - assert.Equal(t, string(val), response.Body.String()) - } - }) - } -} diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index 4f6173eec8e..a586f514e0d 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -316,7 +316,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) ac := acmock.New() cfg.RBACEnabled = false service := publicdashboardsService.ProvideService(cfg, store, qds, annotationsService, ac) - pubdash, err := service.Save(context.Background(), &user.SignedInUser{}, savePubDashboardCmd) + pubdash, err := service.Create(context.Background(), &user.SignedInUser{}, savePubDashboardCmd) require.NoError(t, err) // setup test server diff --git a/pkg/services/publicdashboards/database/database.go b/pkg/services/publicdashboards/database/database.go index 10f2f1bb313..2f9b8de65d4 100644 --- a/pkg/services/publicdashboards/database/database.go +++ b/pkg/services/publicdashboards/database/database.go @@ -66,11 +66,15 @@ func (d *PublicDashboardStoreImpl) FindDashboard(ctx context.Context, orgId int6 return err }) + if err != nil { + return nil, err + } + if !found { return nil, nil } - return dashboard, err + return dashboard, nil } // Find Returns public dashboard by Uid or nil if not found @@ -80,10 +84,10 @@ func (d *PublicDashboardStoreImpl) Find(ctx context.Context, uid string) (*Publi } var found bool - pdRes := &PublicDashboard{Uid: uid} + publicDashboard := &PublicDashboard{Uid: uid} err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { var err error - found, err = sess.Get(pdRes) + found, err = sess.Get(publicDashboard) return err }) @@ -95,7 +99,7 @@ func (d *PublicDashboardStoreImpl) Find(ctx context.Context, uid string) (*Publi return nil, nil } - return pdRes, err + return publicDashboard, nil } // FindByAccessToken Returns public dashboard by access token or nil if not found @@ -105,10 +109,10 @@ func (d *PublicDashboardStoreImpl) FindByAccessToken(ctx context.Context, access } var found bool - pdRes := &PublicDashboard{AccessToken: accessToken} + publicDashboard := &PublicDashboard{AccessToken: accessToken} err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { var err error - found, err = sess.Get(pdRes) + found, err = sess.Get(publicDashboard) return err }) @@ -120,7 +124,7 @@ func (d *PublicDashboardStoreImpl) FindByAccessToken(ctx context.Context, access return nil, nil } - return pdRes, err + return publicDashboard, nil } // FindByDashboardUid Retrieves public dashboard by dashboard uid or nil if not found @@ -128,7 +132,6 @@ func (d *PublicDashboardStoreImpl) FindByDashboardUid(ctx context.Context, orgId if dashboardUid == "" || orgId == 0 { return nil, nil } - var found bool publicDashboard := &PublicDashboard{OrgId: orgId, DashboardUid: dashboardUid} err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { @@ -149,67 +152,7 @@ func (d *PublicDashboardStoreImpl) FindByDashboardUid(ctx context.Context, orgId return nil, nil } - return publicDashboard, err -} - -// Save Persists public dashboard -func (d *PublicDashboardStoreImpl) Save(ctx context.Context, cmd SavePublicDashboardCommand) error { - if cmd.PublicDashboard.DashboardUid == "" { - return dashboards.ErrDashboardIdentifierNotSet - } - - err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - _, err := sess.UseBool("is_enabled").Insert(&cmd.PublicDashboard) - if err != nil { - return err - } - - return nil - }) - - return err -} - -// Update updates existing public dashboard -func (d *PublicDashboardStoreImpl) Update(ctx context.Context, cmd SavePublicDashboardCommand) error { - err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - timeSettingsJSON, err := json.Marshal(cmd.PublicDashboard.TimeSettings) - if err != nil { - return err - } - - _, err = sess.Exec("UPDATE dashboard_public SET is_enabled = ?, annotations_enabled = ?, time_settings = ?, updated_by = ?, updated_at = ? WHERE uid = ?", - cmd.PublicDashboard.IsEnabled, - cmd.PublicDashboard.AnnotationsEnabled, - string(timeSettingsJSON), - cmd.PublicDashboard.UpdatedBy, - cmd.PublicDashboard.UpdatedAt.UTC().Format("2006-01-02 15:04:05"), - cmd.PublicDashboard.Uid) - - if err != nil { - return err - } - - return nil - }) - - return err -} - -func (d *PublicDashboardStoreImpl) Delete(ctx context.Context, orgId int64, uid string) (int64, error) { - dashboard := &PublicDashboard{OrgId: orgId, Uid: uid} - var affectedRows int64 - err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { - var err error - affectedRows, err = sess.Delete(dashboard) - - if err != nil { - return err - } - return nil - }) - - return affectedRows, err + return publicDashboard, nil } // ExistsEnabledByDashboardUid Responds true if there is an enabled public dashboard for a dashboard uid @@ -264,3 +207,62 @@ func (d *PublicDashboardStoreImpl) GetOrgIdByAccessToken(ctx context.Context, ac return orgId, err } + +// Creates a public dashboard +func (d *PublicDashboardStoreImpl) Create(ctx context.Context, cmd SavePublicDashboardCommand) (int64, error) { + if cmd.PublicDashboard.DashboardUid == "" { + return 0, dashboards.ErrDashboardIdentifierNotSet + } + + var affectedRows int64 + err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var err error + affectedRows, err = sess.UseBool("is_enabled").Insert(&cmd.PublicDashboard) + return err + }) + + return affectedRows, err +} + +// Updates existing public dashboard +func (d *PublicDashboardStoreImpl) Update(ctx context.Context, cmd SavePublicDashboardCommand) (int64, error) { + var affectedRows int64 + err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + timeSettingsJSON, err := json.Marshal(cmd.PublicDashboard.TimeSettings) + if err != nil { + return err + } + + sqlResult, err := sess.Exec("UPDATE dashboard_public SET is_enabled = ?, annotations_enabled = ?, time_settings = ?, updated_by = ?, updated_at = ? WHERE uid = ?", + cmd.PublicDashboard.IsEnabled, + cmd.PublicDashboard.AnnotationsEnabled, + string(timeSettingsJSON), + cmd.PublicDashboard.UpdatedBy, + cmd.PublicDashboard.UpdatedAt.UTC().Format("2006-01-02 15:04:05"), + cmd.PublicDashboard.Uid) + + if err != nil { + return err + } + + affectedRows, err = sqlResult.RowsAffected() + + return err + }) + + return affectedRows, err +} + +// Deletes a public dashboard +func (d *PublicDashboardStoreImpl) Delete(ctx context.Context, orgId int64, uid string) (int64, error) { + dashboard := &PublicDashboard{OrgId: orgId, Uid: uid} + var affectedRows int64 + err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var err error + affectedRows, err = sess.Delete(dashboard) + + return err + }) + + return affectedRows, err +} diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index b39c86f4278..4a505a76117 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -103,7 +103,7 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { t.Run("ExistsEnabledByAccessToken will return true when at least one public dashboard has a matching access token", func(t *testing.T) { setup() - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + _, err := publicdashboardStore.Create(context.Background(), SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "abc123", @@ -125,7 +125,7 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { t.Run("ExistsEnabledByAccessToken will return false when IsEnabled=false", func(t *testing.T) { setup() - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + _, err := publicdashboardStore.Create(context.Background(), SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: false, Uid: "abc123", @@ -171,7 +171,7 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { t.Run("ExistsEnabledByDashboardUid Will return true when dashboard has at least one enabled public dashboard", func(t *testing.T) { setup() - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + _, err := publicdashboardStore.Create(context.Background(), SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "abc123", @@ -193,7 +193,7 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { t.Run("ExistsEnabledByDashboardUid will return false when dashboard has public dashboards but they are not enabled", func(t *testing.T) { setup() - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + _, err := publicdashboardStore.Create(context.Background(), SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: false, Uid: "abc123", @@ -257,7 +257,7 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { } // insert test public dashboard - err := publicdashboardStore.Save(context.Background(), cmd) + _, err := publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) // retrieve from db @@ -320,7 +320,7 @@ func TestIntegrationFindByAccessToken(t *testing.T) { } // insert test public dashboard - err := publicdashboardStore.Save(context.Background(), cmd) + _, err := publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) // retrieve from db @@ -338,7 +338,7 @@ func TestIntegrationFindByAccessToken(t *testing.T) { }) } -func TestIntegrationSavePublicDashboard(t *testing.T) { +func TestIntegrationCreatePublicDashboard(t *testing.T) { var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -357,7 +357,7 @@ func TestIntegrationSavePublicDashboard(t *testing.T) { t.Run("saves new public dashboard", func(t *testing.T) { setup() - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + cmd := SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: true, AnnotationsEnabled: true, @@ -369,14 +369,14 @@ func TestIntegrationSavePublicDashboard(t *testing.T) { CreatedBy: 7, AccessToken: "NOTAREALUUID", }, - }) + } + affectedRows, err := publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) + assert.EqualValues(t, affectedRows, 1) pubdash, err := publicdashboardStore.FindByDashboardUid(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) require.NoError(t, err) - - // verify we have a valid uid - assert.True(t, util.IsValidShortUID(pubdash.Uid)) + assert.Equal(t, pubdash.AccessToken, "NOTAREALUUID") // verify we didn't update all dashboards pubdash2, err := publicdashboardStore.FindByDashboardUid(context.Background(), savedDashboard2.OrgId, savedDashboard2.Uid) @@ -386,7 +386,7 @@ func TestIntegrationSavePublicDashboard(t *testing.T) { t.Run("guards from saving without dashboardUid", func(t *testing.T) { setup() - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + cmd := SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "pubdash-uid", @@ -397,9 +397,11 @@ func TestIntegrationSavePublicDashboard(t *testing.T) { CreatedBy: 7, AccessToken: "NOTAREALUUID", }, - }) + } + affectedRows, err := publicdashboardStore.Create(context.Background(), cmd) require.Error(t, err) assert.Equal(t, err, dashboards.ErrDashboardIdentifierNotSet) + assert.EqualValues(t, affectedRows, 0) }) } @@ -423,7 +425,7 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { setup() pdUid := "asdf1234" - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + cmd := SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ Uid: pdUid, DashboardUid: savedDashboard.Uid, @@ -434,12 +436,14 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { CreatedBy: 7, AccessToken: "NOTAREALUUID", }, - }) + } + affectedRows, err := publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) + assert.EqualValues(t, affectedRows, 1) // inserting two different public dashboards to test update works and only affect the desired pd by uid anotherPdUid := "anotherUid" - err = publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + cmd = SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ Uid: anotherPdUid, DashboardUid: anotherSavedDashboard.Uid, @@ -450,8 +454,11 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { CreatedBy: 7, AccessToken: "fakeaccesstoken", }, - }) + } + + affectedRows, err = publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) + assert.EqualValues(t, affectedRows, 1) updatedPublicDashboard := PublicDashboard{ Uid: pdUid, @@ -463,11 +470,12 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { UpdatedAt: time.Now().UTC().Round(time.Second), UpdatedBy: 8, } + // update initial record - err = publicdashboardStore.Update(context.Background(), SavePublicDashboardCommand{ - PublicDashboard: updatedPublicDashboard, - }) + cmd = SavePublicDashboardCommand{PublicDashboard: updatedPublicDashboard} + rowsAffected, err := publicdashboardStore.Update(context.Background(), cmd) require.NoError(t, err) + assert.EqualValues(t, rowsAffected, 1) // updated dashboard should have changed pdRetrieved, err := publicdashboardStore.FindByDashboardUid(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) @@ -503,8 +511,7 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { } t.Run("GetOrgIdByAccessToken will OrgId when enabled", func(t *testing.T) { setup() - - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + cmd := SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "abc123", @@ -514,7 +521,8 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { CreatedBy: 7, AccessToken: "accessToken", }, - }) + } + _, err := publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) orgId, err := publicdashboardStore.GetOrgIdByAccessToken(context.Background(), "accessToken") @@ -525,8 +533,7 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { t.Run("GetOrgIdByAccessToken will return 0 when IsEnabled=false", func(t *testing.T) { setup() - - err := publicdashboardStore.Save(context.Background(), SavePublicDashboardCommand{ + cmd := SavePublicDashboardCommand{ PublicDashboard: PublicDashboard{ IsEnabled: false, Uid: "abc123", @@ -536,8 +543,11 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { CreatedBy: 7, AccessToken: "accessToken", }, - }) + } + + _, err := publicdashboardStore.Create(context.Background(), cmd) require.NoError(t, err) + orgId, err := publicdashboardStore.GetOrgIdByAccessToken(context.Background(), "accessToken") require.NoError(t, err) assert.NotEqual(t, savedDashboard.OrgId, orgId) @@ -634,8 +644,9 @@ func insertPublicDashboard(t *testing.T, publicdashboardStore *PublicDashboardSt }, } - err = publicdashboardStore.Save(ctx, cmd) + affectedRows, err := publicdashboardStore.Create(ctx, cmd) require.NoError(t, err) + assert.EqualValues(t, affectedRows, 1) pubdash, err := publicdashboardStore.Find(ctx, uid) require.NoError(t, err) diff --git a/pkg/services/publicdashboards/internal/tokens/tokens.go b/pkg/services/publicdashboards/internal/tokens/tokens.go index 48e241872fa..4d4f3accf0d 100644 --- a/pkg/services/publicdashboards/internal/tokens/tokens.go +++ b/pkg/services/publicdashboards/internal/tokens/tokens.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/google/uuid" + "github.com/grafana/grafana/pkg/util" ) // GenerateAccessToken generates an uuid formatted without dashes to use as access token @@ -20,3 +21,9 @@ func IsValidAccessToken(token string) bool { _, err := uuid.Parse(token) return err == nil } + +// IsValidShortUID checks that the uid is not blank and contains valid +// characters. Wraps utils.IsValidShortUID +func IsValidShortUID(uid string) bool { + return uid != "" && util.IsValidShortUID(uid) +} diff --git a/pkg/services/publicdashboards/internal/tokens/tokens_test.go b/pkg/services/publicdashboards/internal/tokens/tokens_test.go index fdf0da97a9f..b04ba221f10 100644 --- a/pkg/services/publicdashboards/internal/tokens/tokens_test.go +++ b/pkg/services/publicdashboards/internal/tokens/tokens_test.go @@ -36,3 +36,19 @@ func TestValidAccessToken(t *testing.T) { assert.False(t, IsValidAccessToken("0123456789012345678901234567890123456789")) }) } + +// we just check base cases since this wraps utils.IsValidShortUID which has +// test coverage +func TestValidUid(t *testing.T) { + t.Run("true", func(t *testing.T) { + assert.True(t, IsValidShortUID("afqrz7jZZ")) + }) + + t.Run("false when blank", func(t *testing.T) { + assert.False(t, IsValidShortUID("")) + }) + + t.Run("false when invalid chars", func(t *testing.T) { + assert.False(t, IsValidShortUID("afqrz7j%%")) + }) +} diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index a6b7d51e853..afee7d78e5b 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -25,6 +25,29 @@ type FakePublicDashboardService struct { mock.Mock } +// Create provides a mock function with given fields: ctx, u, dto +func (_m *FakePublicDashboardService) Create(ctx context.Context, u *user.SignedInUser, dto *models.SavePublicDashboardDTO) (*models.PublicDashboard, error) { + ret := _m.Called(ctx, u, dto) + + var r0 *models.PublicDashboard + if rf, ok := ret.Get(0).(func(context.Context, *user.SignedInUser, *models.SavePublicDashboardDTO) *models.PublicDashboard); ok { + r0 = rf(ctx, u, dto) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.PublicDashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *user.SignedInUser, *models.SavePublicDashboardDTO) error); ok { + r1 = rf(ctx, u, dto) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Delete provides a mock function with given fields: ctx, orgId, uid func (_m *FakePublicDashboardService) Delete(ctx context.Context, orgId int64, uid string) error { ret := _m.Called(ctx, orgId, uid) @@ -312,8 +335,8 @@ func (_m *FakePublicDashboardService) NewPublicDashboardUid(ctx context.Context) return r0, r1 } -// Save provides a mock function with given fields: ctx, u, dto -func (_m *FakePublicDashboardService) Save(ctx context.Context, u *user.SignedInUser, dto *models.SavePublicDashboardDTO) (*models.PublicDashboard, error) { +// Update provides a mock function with given fields: ctx, u, dto +func (_m *FakePublicDashboardService) Update(ctx context.Context, u *user.SignedInUser, dto *models.SavePublicDashboardDTO) (*models.PublicDashboard, error) { ret := _m.Called(ctx, u, dto) var r0 *models.PublicDashboard diff --git a/pkg/services/publicdashboards/public_dashboard_store_mock.go b/pkg/services/publicdashboards/public_dashboard_store_mock.go index eafc1b92a50..a664cb9cc2c 100644 --- a/pkg/services/publicdashboards/public_dashboard_store_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_store_mock.go @@ -18,6 +18,27 @@ type FakePublicDashboardStore struct { mock.Mock } +// Create provides a mock function with given fields: ctx, cmd +func (_m *FakePublicDashboardStore) Create(ctx context.Context, cmd models.SavePublicDashboardCommand) (int64, error) { + ret := _m.Called(ctx, cmd) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardCommand) int64); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.SavePublicDashboardCommand) error); ok { + r1 = rf(ctx, cmd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Delete provides a mock function with given fields: ctx, orgId, uid func (_m *FakePublicDashboardStore) Delete(ctx context.Context, orgId int64, uid string) (int64, error) { ret := _m.Called(ctx, orgId, uid) @@ -217,32 +238,25 @@ func (_m *FakePublicDashboardStore) GetOrgIdByAccessToken(ctx context.Context, a return r0, r1 } -// Save provides a mock function with given fields: ctx, cmd -func (_m *FakePublicDashboardStore) Save(ctx context.Context, cmd models.SavePublicDashboardCommand) error { - ret := _m.Called(ctx, cmd) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardCommand) error); ok { - r0 = rf(ctx, cmd) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Update provides a mock function with given fields: ctx, cmd -func (_m *FakePublicDashboardStore) Update(ctx context.Context, cmd models.SavePublicDashboardCommand) error { +func (_m *FakePublicDashboardStore) Update(ctx context.Context, cmd models.SavePublicDashboardCommand) (int64, error) { ret := _m.Called(ctx, cmd) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardCommand) error); ok { + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardCommand) int64); ok { r0 = rf(ctx, cmd) } else { - r0 = ret.Error(0) + r0 = ret.Get(0).(int64) } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.SavePublicDashboardCommand) error); ok { + r1 = rf(ctx, cmd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // NewFakePublicDashboardStore creates a new instance of FakePublicDashboardStore. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index 6c1163e0fc5..cf019141255 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -20,7 +20,8 @@ type Service interface { FindAnnotations(ctx context.Context, reqDTO AnnotationsQueryDTO, accessToken string) ([]AnnotationEvent, error) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*models.Dashboard, error) FindAll(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error) - Save(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) + Create(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) + Update(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) Delete(ctx context.Context, orgId int64, uid string) error GetMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64, reqDTO PublicDashboardQueryDTO) (dtos.MetricRequest, error) @@ -40,8 +41,8 @@ type Store interface { FindByDashboardUid(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*models.Dashboard, error) FindAll(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) - Save(ctx context.Context, cmd SavePublicDashboardCommand) error - Update(ctx context.Context, cmd SavePublicDashboardCommand) error + Create(ctx context.Context, cmd SavePublicDashboardCommand) (int64, error) + Update(ctx context.Context, cmd SavePublicDashboardCommand) (int64, error) Delete(ctx context.Context, orgId int64, uid string) (int64, error) GetOrgIdByAccessToken(ctx context.Context, accessToken string) (int64, error) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 80e81d8da95..366fe40c259 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -399,7 +399,7 @@ func TestGetQueryDataResponse(t *testing.T) { TimeSettings: timeSettings, }, } - pubdashDto, err := service.Save(context.Background(), SignedInUser, dto) + pubdashDto, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) resp, _ := service.GetQueryDataResponse(context.Background(), true, publicDashboardQueryDTO, 1, pubdashDto.AccessToken) @@ -840,7 +840,7 @@ func TestBuildMetricRequest(t *testing.T) { }, } - publicDashboardPD, err := service.Save(context.Background(), SignedInUser, dto) + publicDashboardPD, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) nonPublicDto := &SavePublicDashboardDTO{ @@ -854,7 +854,7 @@ func TestBuildMetricRequest(t *testing.T) { }, } - _, err = service.Save(context.Background(), SignedInUser, nonPublicDto) + _, err = service.Create(context.Background(), SignedInUser, nonPublicDto) require.NoError(t, err) t.Run("extracts queries from provided dashboard", func(t *testing.T) { diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 766008e43ef..29685c36bc4 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -121,9 +121,76 @@ func (pd *PublicDashboardServiceImpl) FindByDashboardUid(ctx context.Context, or return pubdash, nil } -// Save is a helper method to persist the sharing config -// to the database. It handles validations for sharing config and persistence -func (pd *PublicDashboardServiceImpl) Save(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) { +// Creates and validates the public dashboard and saves it to the database +func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) { + // ensure dashboard exists + dashboard, err := pd.FindDashboard(ctx, u.OrgID, dto.DashboardUid) + if err != nil { + return nil, err + } + + // set default value for time settings + if dto.PublicDashboard.TimeSettings == nil { + dto.PublicDashboard.TimeSettings = &TimeSettings{} + } + + // validate fields + err = validation.ValidatePublicDashboard(dto, dashboard) + if err != nil { + return nil, err + } + + // verify public dashboard does not exist and that we didn't get one from the + // request + existingPubdash, err := pd.store.Find(ctx, dto.PublicDashboard.Uid) + if err != nil { + return nil, err + } else if existingPubdash != nil { + return nil, ErrPublicDashboardBadRequest + } + + uid, err := pd.NewPublicDashboardUid(ctx) + if err != nil { + return nil, err + } + + accessToken, err := pd.NewPublicDashboardAccessToken(ctx) + if err != nil { + return nil, err + } + + cmd := SavePublicDashboardCommand{ + PublicDashboard: PublicDashboard{ + Uid: uid, + DashboardUid: dto.DashboardUid, + OrgId: dto.OrgId, + IsEnabled: dto.PublicDashboard.IsEnabled, + AnnotationsEnabled: dto.PublicDashboard.AnnotationsEnabled, + TimeSettings: dto.PublicDashboard.TimeSettings, + CreatedBy: dto.UserId, + CreatedAt: time.Now(), + AccessToken: accessToken, + }, + } + + _, err = pd.store.Create(ctx, cmd) + if err != nil { + return nil, err + } + + //Get latest public dashboard to return + newPubdash, err := pd.store.Find(ctx, uid) + if err != nil { + return nil, err + } + + pd.logIsEnabledChanged(existingPubdash, newPubdash, u) + + return newPubdash, err +} + +// Updates an existing public dashboard based on publicdashboard.Uid +func (pd *PublicDashboardServiceImpl) Update(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) { // validate if the dashboard exists dashboard, err := pd.FindDashboard(ctx, u.OrgID, dto.DashboardUid) if err != nil { @@ -143,25 +210,41 @@ func (pd *PublicDashboardServiceImpl) Save(ctx context.Context, u *user.SignedIn existingPubdash, err := pd.store.Find(ctx, dto.PublicDashboard.Uid) if err != nil { return nil, err + } else if existingPubdash == nil { + return nil, ErrPublicDashboardNotFound } - // save changes - var pubdashUid string - if existingPubdash == nil { - err = validation.ValidateSavePublicDashboard(dto, dashboard) - if err != nil { - return nil, err - } - pubdashUid, err = pd.savePublicDashboard(ctx, dto) - } else { - pubdashUid, err = pd.updatePublicDashboard(ctx, dto) - } + // validate dashboard + err = validation.ValidatePublicDashboard(dto, dashboard) if err != nil { return nil, err } - //Get latest public dashboard to return - newPubdash, err := pd.store.Find(ctx, pubdashUid) + // set values to update + cmd := SavePublicDashboardCommand{ + PublicDashboard: PublicDashboard{ + Uid: existingPubdash.Uid, + IsEnabled: dto.PublicDashboard.IsEnabled, + AnnotationsEnabled: dto.PublicDashboard.AnnotationsEnabled, + TimeSettings: dto.PublicDashboard.TimeSettings, + UpdatedBy: dto.UserId, + UpdatedAt: time.Now(), + }, + } + + // persist + affectedRows, err := pd.store.Update(ctx, cmd) + if err != nil { + return nil, err + } + + // 404 if not found + if affectedRows == 0 { + return nil, ErrPublicDashboardNotFound + } + + // get latest public dashboard to return + newPubdash, err := pd.store.Find(ctx, existingPubdash.Uid) if err != nil { return nil, err } @@ -203,58 +286,6 @@ func (pd *PublicDashboardServiceImpl) NewPublicDashboardAccessToken(ctx context. return "", ErrPublicDashboardFailedGenerateAccessToken } -// Called by Save this handles business logic -// to generate token and calls create at the database layer -func (pd *PublicDashboardServiceImpl) savePublicDashboard(ctx context.Context, dto *SavePublicDashboardDTO) (string, error) { - uid, err := pd.NewPublicDashboardUid(ctx) - if err != nil { - return "", err - } - - accessToken, err := pd.NewPublicDashboardAccessToken(ctx) - if err != nil { - return "", err - } - - cmd := SavePublicDashboardCommand{ - PublicDashboard: PublicDashboard{ - Uid: uid, - DashboardUid: dto.DashboardUid, - OrgId: dto.OrgId, - IsEnabled: dto.PublicDashboard.IsEnabled, - AnnotationsEnabled: dto.PublicDashboard.AnnotationsEnabled, - TimeSettings: dto.PublicDashboard.TimeSettings, - CreatedBy: dto.UserId, - CreatedAt: time.Now(), - AccessToken: accessToken, - }, - } - - err = pd.store.Save(ctx, cmd) - if err != nil { - return "", err - } - - return uid, nil -} - -// Called by Save this handles business logic for updating a -// dashboard and calls update at the database layer -func (pd *PublicDashboardServiceImpl) updatePublicDashboard(ctx context.Context, dto *SavePublicDashboardDTO) (string, error) { - cmd := SavePublicDashboardCommand{ - PublicDashboard: PublicDashboard{ - Uid: dto.PublicDashboard.Uid, - IsEnabled: dto.PublicDashboard.IsEnabled, - AnnotationsEnabled: dto.PublicDashboard.AnnotationsEnabled, - TimeSettings: dto.PublicDashboard.TimeSettings, - UpdatedBy: dto.UserId, - UpdatedAt: time.Now(), - }, - } - - return dto.PublicDashboard.Uid, pd.store.Update(ctx, cmd) -} - // FindAll Returns a list of public dashboards by orgId func (pd *PublicDashboardServiceImpl) FindAll(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error) { publicDashboards, err := pd.store.FindAll(ctx, orgId) diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 36d37601377..82dec54da3d 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -120,8 +120,10 @@ func TestGetPublicDashboard(t *testing.T) { } } -func TestSavePublicDashboard(t *testing.T) { - t.Run("Saving public dashboard", func(t *testing.T) { +// We're using sqlite here because testing all of the behaviors with mocks in +// the correct order is convoluted. +func TestCreatePublicDashboard(t *testing.T) { + t.Run("Create public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) @@ -145,7 +147,7 @@ func TestSavePublicDashboard(t *testing.T) { }, } - _, err := service.Save(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -189,7 +191,7 @@ func TestSavePublicDashboard(t *testing.T) { }, } - _, err := service.Save(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -220,11 +222,11 @@ func TestSavePublicDashboard(t *testing.T) { }, } - _, err := service.Save(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.Error(t, err) }) - t.Run("Pubdash access token generation throws an error and pubdash is not persisted", func(t *testing.T) { + t.Run("Throws an error when pubdash with generated access token already exists", func(t *testing.T) { dashboard := models.NewDashboard("testDashie") pubdash := &PublicDashboard{ IsEnabled: true, @@ -238,7 +240,6 @@ func TestSavePublicDashboard(t *testing.T) { publicDashboardStore.On("FindDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) publicDashboardStore.On("Find", mock.Anything, mock.Anything).Return(nil, nil) publicDashboardStore.On("FindByAccessToken", mock.Anything, mock.Anything).Return(pubdash, nil) - publicDashboardStore.On("NewPublicDashboardUid", mock.Anything).Return("an-uid", nil) service := &PublicDashboardServiceImpl{ log: log.New("test.logger"), @@ -256,11 +257,59 @@ func TestSavePublicDashboard(t *testing.T) { }, } - _, err := service.Save(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.Error(t, err) require.Equal(t, err, ErrPublicDashboardFailedGenerateAccessToken) - publicDashboardStore.AssertNotCalled(t, "Save") + publicDashboardStore.AssertNotCalled(t, "Create") + }) + + t.Run("Returns error if public dashboard exists", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + publicdashboardStore := database.ProvideStore(sqlStore) + dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) + + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, + } + + dto := &SavePublicDashboardDTO{ + DashboardUid: dashboard.Uid, + OrgId: dashboard.OrgId, + UserId: 7, + PublicDashboard: &PublicDashboard{ + AnnotationsEnabled: false, + IsEnabled: true, + TimeSettings: timeSettings, + }, + } + + savedPubdash, err := service.Create(context.Background(), SignedInUser, dto) + require.NoError(t, err) + + // attempt to overwrite settings + dto = &SavePublicDashboardDTO{ + DashboardUid: dashboard.Uid, + OrgId: dashboard.OrgId, + UserId: 8, + PublicDashboard: &PublicDashboard{ + Uid: savedPubdash.Uid, + OrgId: 9, + DashboardUid: "abc1234", + CreatedBy: 9, + CreatedAt: time.Time{}, + + IsEnabled: true, + AnnotationsEnabled: true, + TimeSettings: timeSettings, + AccessToken: "NOTAREALUUID", + }, + } + + _, err = service.Create(context.Background(), SignedInUser, dto) + assert.Equal(t, ErrPublicDashboardBadRequest, err) }) } @@ -287,7 +336,8 @@ func TestUpdatePublicDashboard(t *testing.T) { }, } - savedPubdash, err := service.Save(context.Background(), SignedInUser, dto) + // insert initial pubdash + savedPubdash, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) // attempt to overwrite settings @@ -308,10 +358,7 @@ func TestUpdatePublicDashboard(t *testing.T) { AccessToken: "NOTAREALUUID", }, } - - // Since the dto.PublicDashboard has a uid, this will call - // service.updatePublicDashboard - updatedPubdash, err := service.Save(context.Background(), SignedInUser, dto) + updatedPubdash, err := service.Update(context.Background(), SignedInUser, dto) require.NoError(t, err) // don't get updated @@ -350,9 +397,7 @@ func TestUpdatePublicDashboard(t *testing.T) { }, } - // Since the dto.PublicDashboard has a uid, this will call - // service.updatePublicDashboard - savedPubdash, err := service.Save(context.Background(), SignedInUser, dto) + savedPubdash, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) // attempt to overwrite settings @@ -372,7 +417,7 @@ func TestUpdatePublicDashboard(t *testing.T) { }, } - updatedPubdash, err := service.Save(context.Background(), SignedInUser, dto) + updatedPubdash, err := service.Update(context.Background(), SignedInUser, dto) require.NoError(t, err) assert.Equal(t, &TimeSettings{}, updatedPubdash.TimeSettings) @@ -422,81 +467,6 @@ func TestDeletePublicDashboard(t *testing.T) { } } -func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64, - folderId int64, isFolder bool, templateVars []map[string]interface{}, customPanels []interface{}, tags ...interface{}) *models.Dashboard { - t.Helper() - - var dashboardPanels []interface{} - if customPanels != nil { - dashboardPanels = customPanels - } else { - dashboardPanels = []interface{}{ - map[string]interface{}{ - "id": 1, - "datasource": map[string]interface{}{ - "uid": "ds1", - }, - "targets": []interface{}{ - map[string]interface{}{ - "datasource": map[string]interface{}{ - "type": "mysql", - "uid": "ds1", - }, - "refId": "A", - }, - map[string]interface{}{ - "datasource": map[string]interface{}{ - "type": "prometheus", - "uid": "ds2", - }, - "refId": "B", - }, - }, - }, - map[string]interface{}{ - "id": 2, - "datasource": map[string]interface{}{ - "uid": "ds3", - }, - "targets": []interface{}{ - map[string]interface{}{ - "datasource": map[string]interface{}{ - "type": "mysql", - "uid": "ds3", - }, - "refId": "C", - }, - }, - }, - } - } - - cmd := models.SaveDashboardCommand{ - OrgId: orgId, - FolderId: folderId, - IsFolder: isFolder, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": title, - "tags": tags, - "panels": dashboardPanels, - "templating": map[string]interface{}{ - "list": templateVars, - }, - "time": map[string]interface{}{ - "from": "2022-09-01T00:00:00.000Z", - "to": "2022-09-01T12:00:00.000Z", - }, - }), - } - dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) - require.NoError(t, err) - require.NotNil(t, dash) - dash.Data.Set("id", dash.Id) - dash.Data.Set("uid", dash.Uid) - return dash -} - func TestPublicDashboardServiceImpl_getSafeIntervalAndMaxDataPoints(t *testing.T) { type args struct { reqDTO PublicDashboardQueryDTO @@ -596,36 +566,6 @@ func TestDashboardEnabledChanged(t *testing.T) { }) } -func CreateDatasource(dsType string, uid string) struct { - Type *string `json:"type,omitempty"` - Uid *string `json:"uid,omitempty"` -} { - return struct { - Type *string `json:"type,omitempty"` - Uid *string `json:"uid,omitempty"` - }{ - Type: &dsType, - Uid: &uid, - } -} - -func AddAnnotationsToDashboard(t *testing.T, dash *models.Dashboard, annotations []DashAnnotation) *models.Dashboard { - type annotationsDto struct { - List []DashAnnotation `json:"list"` - } - annos := annotationsDto{} - annos.List = annotations - annoJSON, err := json.Marshal(annos) - require.NoError(t, err) - - dashAnnos, err := simplejson.NewJson(annoJSON) - require.NoError(t, err) - - dash.Data.Set("annotations", dashAnnos) - - return dash -} - func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { type args struct { ctx context.Context @@ -962,3 +902,108 @@ func TestPublicDashboardServiceImpl_NewPublicDashboardAccessToken(t *testing.T) }) } } + +func CreateDatasource(dsType string, uid string) struct { + Type *string `json:"type,omitempty"` + Uid *string `json:"uid,omitempty"` +} { + return struct { + Type *string `json:"type,omitempty"` + Uid *string `json:"uid,omitempty"` + }{ + Type: &dsType, + Uid: &uid, + } +} + +func AddAnnotationsToDashboard(t *testing.T, dash *models.Dashboard, annotations []DashAnnotation) *models.Dashboard { + type annotationsDto struct { + List []DashAnnotation `json:"list"` + } + annos := annotationsDto{} + annos.List = annotations + annoJSON, err := json.Marshal(annos) + require.NoError(t, err) + + dashAnnos, err := simplejson.NewJson(annoJSON) + require.NoError(t, err) + + dash.Data.Set("annotations", dashAnnos) + + return dash +} + +func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64, + folderId int64, isFolder bool, templateVars []map[string]interface{}, customPanels []interface{}, tags ...interface{}) *models.Dashboard { + t.Helper() + + var dashboardPanels []interface{} + if customPanels != nil { + dashboardPanels = customPanels + } else { + dashboardPanels = []interface{}{ + map[string]interface{}{ + "id": 1, + "datasource": map[string]interface{}{ + "uid": "ds1", + }, + "targets": []interface{}{ + map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": "mysql", + "uid": "ds1", + }, + "refId": "A", + }, + map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": "prometheus", + "uid": "ds2", + }, + "refId": "B", + }, + }, + }, + map[string]interface{}{ + "id": 2, + "datasource": map[string]interface{}{ + "uid": "ds3", + }, + "targets": []interface{}{ + map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": "mysql", + "uid": "ds3", + }, + "refId": "C", + }, + }, + }, + } + } + + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + "panels": dashboardPanels, + "templating": map[string]interface{}{ + "list": templateVars, + }, + "time": map[string]interface{}{ + "from": "2022-09-01T00:00:00.000Z", + "to": "2022-09-01T12:00:00.000Z", + }, + }), + } + dash, err := dashboardStore.SaveDashboard(context.Background(), cmd) + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + return dash +} diff --git a/pkg/services/publicdashboards/validation/validation.go b/pkg/services/publicdashboards/validation/validation.go index 5eda57dfcc4..d0b4936625e 100644 --- a/pkg/services/publicdashboards/validation/validation.go +++ b/pkg/services/publicdashboards/validation/validation.go @@ -7,7 +7,7 @@ import ( . "github.com/grafana/grafana/pkg/services/publicdashboards/models" ) -func ValidateSavePublicDashboard(dto *SavePublicDashboardDTO, dashboard *models.Dashboard) error { +func ValidatePublicDashboard(dto *SavePublicDashboardDTO, dashboard *models.Dashboard) error { if hasTemplateVariables(dashboard) { return ErrPublicDashboardHasTemplateVariables } diff --git a/pkg/services/publicdashboards/validation/validation_test.go b/pkg/services/publicdashboards/validation/validation_test.go index 5d189f99e9d..1273ca3191f 100644 --- a/pkg/services/publicdashboards/validation/validation_test.go +++ b/pkg/services/publicdashboards/validation/validation_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestValidateSavePublicDashboard(t *testing.T) { +func TestValidatePublicDashboard(t *testing.T) { t.Run("Returns validation error when dashboard has template variables", func(t *testing.T) { templateVars := []byte(`{ "templating": { @@ -24,7 +24,7 @@ func TestValidateSavePublicDashboard(t *testing.T) { dashboard := models.NewDashboardFromJson(dashboardData) dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: nil} - err := ValidateSavePublicDashboard(dto, dashboard) + err := ValidatePublicDashboard(dto, dashboard) require.ErrorContains(t, err, ErrPublicDashboardHasTemplateVariables.Reason) }) @@ -38,7 +38,7 @@ func TestValidateSavePublicDashboard(t *testing.T) { dashboard := models.NewDashboardFromJson(dashboardData) dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: nil} - err := ValidateSavePublicDashboard(dto, dashboard) + err := ValidatePublicDashboard(dto, dashboard) require.NoError(t, err) }) } diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index 6c4b6ca625d..460739f7581 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -35,10 +35,10 @@ const getConfigError = (err: { status: number }) => ({ error: err.status !== 404 export const publicDashboardApi = createApi({ reducerPath: 'publicDashboardApi', baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 0 }), - tagTypes: ['Config', 'PublicDashboards'], + tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard'], keepUnusedDataFor: 0, endpoints: (builder) => ({ - getConfig: builder.query({ + getPublicDashboard: builder.query({ query: (dashboardUid) => ({ url: `/uid/${dashboardUid}/public-dashboards`, manageError: getConfigError, @@ -53,9 +53,9 @@ export const publicDashboardApi = createApi({ dispatch(notifyApp(createErrorNotification(customError?.error?.data?.message))); } }, - providesTags: ['Config'], + providesTags: ['PublicDashboard'], }), - saveConfig: builder.mutation({ + createPublicDashboard: builder.mutation({ query: (params) => ({ url: `/uid/${params.dashboard.uid}/public-dashboards`, method: 'POST', @@ -63,21 +63,42 @@ export const publicDashboardApi = createApi({ }), async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) { const { data } = await queryFulfilled; - dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved'))); + dispatch(notifyApp(createSuccessNotification('Public dashboard created!'))); // Update runtime meta flag dashboard.updateMeta({ + hasPublicDashboard: true, publicDashboardUid: data.uid, publicDashboardEnabled: data.isEnabled, }); }, - invalidatesTags: ['Config'], + invalidatesTags: ['PublicDashboard'], + }), + updatePublicDashboard: builder.mutation({ + query: (params) => ({ + url: `/uid/${params.dashboard.uid}/public-dashboards/${params.payload.uid}`, + method: 'PUT', + data: params.payload, + }), + extraOptions: { maxRetries: 0 }, + async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) { + const { data } = await queryFulfilled; + dispatch(notifyApp(createSuccessNotification('Public dashboard updated!'))); + + // Update runtime meta flag + dashboard.updateMeta({ + hasPublicDashboard: true, + publicDashboardUid: data.uid, + publicDashboardEnabled: data.isEnabled, + }); + }, + invalidatesTags: ['PublicDashboard'], }), listPublicDashboards: builder.query({ query: () => ({ url: '/public-dashboards', }), - providesTags: ['PublicDashboards'], + providesTags: ['AuditTablePublicDashboard'], }), deletePublicDashboard: builder.mutation({ query: (params) => ({ @@ -97,14 +118,15 @@ export const publicDashboardApi = createApi({ ) ); }, - invalidatesTags: ['PublicDashboards'], + invalidatesTags: ['AuditTablePublicDashboard'], }), }), }); export const { - useGetConfigQuery, - useSaveConfigMutation, + useGetPublicDashboardQuery, + useCreatePublicDashboardMutation, + useUpdatePublicDashboardMutation, useDeletePublicDashboardMutation, useListPublicDashboardsQuery, } = publicDashboardApi; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index 06a3fe59eef..15cdbec7a7d 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -17,20 +17,7 @@ import { configureStore } from 'app/store/configureStore'; import { ShareModal } from '../ShareModal'; -const server = setupServer( - rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - isEnabled: false, - annotationsEnabled: false, - uid: undefined, - dashboardUid: undefined, - accessToken: 'an-access-token', - }) - ); - }) -); +const server = setupServer(); jest.mock('@grafana/runtime', () => ({ ...(jest.requireActual('@grafana/runtime') as unknown as object), @@ -147,6 +134,7 @@ describe('SharePublic', () => { expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 01:59:59')).toBeInTheDocument(); }); it('when modal is opened, then loader spinner appears and inputs are disabled', async () => { + mockDashboard.meta.hasPublicDashboard = true; await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); @@ -158,6 +146,7 @@ describe('SharePublic', () => { expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); }); it('when fetch errors happen, then all inputs remain disabled', async () => { + mockDashboard.meta.hasPublicDashboard = true; server.use( rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { return res(ctx.status(500)); @@ -165,7 +154,7 @@ describe('SharePublic', () => { ); await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner'), { timeout: 7000 }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); @@ -178,13 +167,16 @@ describe('SharePublic', () => { }); describe('SharePublic - New config setup', () => { + beforeEach(() => { + mockDashboard.meta.hasPublicDashboard = false; + }); it('when modal is opened, then save button is disabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); }); - it('when fetch is done, then loader spinner is gone, inputs are enabled and save button is disabled', async () => { + it('when fetch is done, then no loader spinner appears, inputs are enabled and save button is disabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeEnabled(); expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeEnabled(); @@ -196,7 +188,7 @@ describe('SharePublic - New config setup', () => { }); it('when checkboxes are filled, then save button remains disabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId(selectors.WillBePublicCheckbox)); fireEvent.click(screen.getByTestId(selectors.LimitedDSCheckbox)); @@ -206,7 +198,7 @@ describe('SharePublic - New config setup', () => { }); it('when checkboxes and switch are filled, then save button is enabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId(selectors.WillBePublicCheckbox)); fireEvent.click(screen.getByTestId(selectors.LimitedDSCheckbox)); @@ -219,6 +211,7 @@ describe('SharePublic - New config setup', () => { describe('SharePublic - Already persisted', () => { beforeEach(() => { + mockDashboard.meta.hasPublicDashboard = true; server.use( rest.get('/api/dashboards/uid/:dashboardUid/public-dashboards', (req, res, ctx) => { return res( diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx index 36c801ea48a..4af0c3d7ff8 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx @@ -6,7 +6,11 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { reportInteraction } from '@grafana/runtime/src'; import { Alert, Button, ClipboardButton, Field, HorizontalGroup, Input, useStyles2, Spinner } from '@grafana/ui/src'; import { contextSrv } from 'app/core/services/context_srv'; -import { useGetConfigQuery, useSaveConfigMutation } from 'app/features/dashboard/api/publicDashboardApi'; +import { + useGetPublicDashboardQuery, + useCreatePublicDashboardMutation, + useUpdatePublicDashboardMutation, +} from 'app/features/dashboard/api/publicDashboardApi'; import { AcknowledgeCheckboxes } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/AcknowledgeCheckboxes'; import { Configuration } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/Configuration'; import { Description } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/Description'; @@ -27,13 +31,19 @@ export const SharePublicDashboard = (props: Props) => { const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; const styles = useStyles2(getStyles); + const [hasPublicDashboard, setHasPublicDashboard] = useState(props.dashboard.meta.hasPublicDashboard); + const { isLoading: isFetchingLoading, data: publicDashboard, isError: isFetchingError, - } = useGetConfigQuery(props.dashboard.uid); + } = useGetPublicDashboardQuery(props.dashboard.uid, { + // if we don't have a public dashboard, don't try to load public dashboard + skip: !hasPublicDashboard, + }); - const [saveConfig, { isLoading: isSaveLoading }] = useSaveConfigMutation(); + const [createPublicDashboard, { isLoading: isSaveLoading }] = useCreatePublicDashboardMutation(); + const [updatePublicDashboard, { isLoading: isUpdateLoading }] = useUpdatePublicDashboardMutation(); const [acknowledgements, setAcknowledgements] = useState({ public: false, @@ -63,7 +73,7 @@ export const SharePublicDashboard = (props: Props) => { setEnabledSwitch((prevState) => ({ ...prevState, isEnabled: !!publicDashboard?.isEnabled })); }, [publicDashboard]); - const isLoading = isFetchingLoading || isSaveLoading; + const isLoading = isFetchingLoading || isSaveLoading || isUpdateLoading; const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin()); const acknowledged = acknowledgements.public && acknowledgements.datasources && acknowledgements.usage; const isSaveEnabled = useMemo( @@ -77,13 +87,23 @@ export const SharePublicDashboard = (props: Props) => { [hasWritePermissions, acknowledged, props.dashboard, isLoading, isFetchingError, enabledSwitch, publicDashboard] ); - const onSavePublicConfig = () => { + const onSavePublicConfig = async () => { reportInteraction('grafana_dashboards_public_create_clicked'); - saveConfig({ + const req = { dashboard: props.dashboard, payload: { ...publicDashboard!, isEnabled: enabledSwitch.isEnabled, annotationsEnabled }, - }); + }; + + // create or update based on whether we have existing uid + + if (hasPublicDashboard) { + await updatePublicDashboard(req).unwrap(); + setHasPublicDashboard(true); + } else { + await createPublicDashboard(req).unwrap(); + setHasPublicDashboard(true); + } }; const onAcknowledge = (field: string, checked: boolean) => { diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 2958f366dbe..1db125255cc 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -44,6 +44,7 @@ export interface DashboardMeta { publicDashboardAccessToken?: string; publicDashboardUid?: string; publicDashboardEnabled?: boolean; + hasPublicDashboard?: boolean; dashboardNotFound?: boolean; } From 376f4b0cc76d24dbfab8fc13cad7ec30abbcd2be Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 3 Nov 2022 21:19:42 +0100 Subject: [PATCH 026/926] Navigation: Add `pluginId` to standalone plugin page NavLinks (#57769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Navigation): add `pluginId` to NavLink and override sibling navlinks with the same URL * test replacing page from plugin * chore: fix go lint issues * fix(NavLink): change `PluginId` to `PluginID` Co-authored-by: Torkel Ödegaard * fix(NavLink): make the `PluginId` -> `PluginID` change everywhere * chore(navModel.ts): update explanatory comment for `pluginId` Co-authored-by: Miklós Tolnai Co-authored-by: Torkel Ödegaard --- packages/grafana-data/src/types/navModel.ts | 2 + pkg/services/navtree/models.go | 1 + pkg/services/navtree/navtreeimpl/applinks.go | 32 ++++++++++++--- .../navtree/navtreeimpl/applinks_test.go | 41 ++++++++++++++++++- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 2b2e1ceece6..dce924e8d9b 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -26,6 +26,8 @@ export interface NavLinkDTO { children?: NavLinkDTO[]; highlightText?: string; emptyMessageId?: string; + // The ID of the plugin that registered the page (in case it was registered by a plugin, otherwise left empty) + pluginId?: string; } export interface NavModelItem extends NavLinkDTO { diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index dc579ffbe29..00358200547 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -67,6 +67,7 @@ type NavLink struct { HighlightText string `json:"highlightText,omitempty"` HighlightID string `json:"highlightId,omitempty"` EmptyMessageId string `json:"emptyMessageId,omitempty"` + PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page) } func (node *NavLink) Sort() { diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index e8bab53a813..b67bd8f0208 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -72,6 +72,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo Section: navtree.NavSectionPlugin, SortWeight: navtree.WeightPlugin, IsSection: true, + PluginID: plugin.ID, } if topNavEnabled { @@ -87,8 +88,9 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo if include.Type == "page" && include.AddToNav { link := &navtree.NavLink{ - Text: include.Name, - Icon: include.Icon, + Text: include.Name, + Icon: include.Icon, + PluginID: plugin.ID, } if len(include.Path) > 0 { @@ -100,11 +102,30 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo link.Url = s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug } + // Register standalone plugin pages to certain sections using the Grafana config if pathConfig, ok := s.navigationAppPathConfig[include.Path]; ok { if sectionForPage := treeRoot.FindById(pathConfig.SectionID); sectionForPage != nil { link.Id = "standalone-plugin-page-" + include.Path link.SortWeight = pathConfig.SortWeight - sectionForPage.Children = append(sectionForPage.Children, link) + + // Check if the section already has a page with the same URL, and in that case override it + // (This only happens if it is explicitly set by `navigation.app_standalone_pages` in the INI config) + isOverridingCorePage := false + for _, child := range sectionForPage.Children { + if child.Url == link.Url { + child.Id = link.Id + child.SortWeight = link.SortWeight + child.PluginID = link.PluginID + child.Children = []*navtree.NavLink{} + isOverridingCorePage = true + break + } + } + + // Append the page to the section + if !isOverridingCorePage { + sectionForPage.Children = append(sectionForPage.Children, link) + } } } else { appLink.Children = append(appLink.Children, link) @@ -115,8 +136,9 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo dboardURL := include.DashboardURLPath() if dboardURL != "" { link := &navtree.NavLink{ - Url: path.Join(s.cfg.AppSubURL, dboardURL), - Text: include.Name, + Url: path.Join(s.cfg.AppSubURL, dboardURL), + Text: include.Name, + PluginID: plugin.ID, } appLink.Children = append(appLink.Children, link) } diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index d6397d680d7..82b791090ad 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -65,9 +65,27 @@ func TestAddAppLinks(t *testing.T) { }, } + testApp3 := plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: "test-app3", + Name: "Test app3 name", + Type: plugins.App, + Includes: []*plugins.Includes{ + { + Name: "Hello", + Path: "/connections/connect-data", + Type: "page", + AddToNav: true, + DefaultNav: true, + }, + }, + }, + } + pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{ testApp1.ID: {ID: 0, OrgID: 1, PluginID: testApp1.ID, PluginVersion: "1.0.0", Enabled: true}, testApp2.ID: {ID: 0, OrgID: 1, PluginID: testApp2.ID, PluginVersion: "1.0.0", Enabled: true}, + testApp3.ID: {ID: 0, OrgID: 1, PluginID: testApp3.ID, PluginVersion: "1.0.0", Enabled: true}, }} service := ServiceImpl{ @@ -77,7 +95,7 @@ func TestAddAppLinks(t *testing.T) { pluginSettings: &pluginSettings, features: featuremgmt.WithFeatures(), pluginStore: plugins.FakePluginStore{ - PluginList: []plugins.PluginDTO{testApp1, testApp2}, + PluginList: []plugins.PluginDTO{testApp1, testApp2, testApp3}, }, } @@ -172,6 +190,27 @@ func TestAddAppLinks(t *testing.T) { require.Equal(t, "Test app2 name", treeRoot.Children[0].Children[0].Text) require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[1].Text) }) + + t.Run("Should replace page from plugin", func(t *testing.T) { + service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav, featuremgmt.FlagDataConnectionsConsole) + service.navigationAppPathConfig = map[string]NavigationAppConfig{ + "/connections/connect-data": {SectionID: "connections"}, + } + + treeRoot := navtree.NavTreeRoot{} + treeRoot.AddSection(service.buildDataConnectionsNavLink(reqCtx)) + require.Equal(t, "Connections", treeRoot.Children[0].Text) + require.Equal(t, "Connect Data", treeRoot.Children[0].Children[1].Text) + require.Equal(t, "connections-connect-data", treeRoot.Children[0].Children[1].Id) + require.Equal(t, "", treeRoot.Children[0].Children[1].PluginID) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Equal(t, "Connections", treeRoot.Children[0].Text) + require.Equal(t, "Connect Data", treeRoot.Children[0].Children[1].Text) + require.Equal(t, "standalone-plugin-page-/connections/connect-data", treeRoot.Children[0].Children[1].Id) + require.Equal(t, "test-app3", treeRoot.Children[0].Children[1].PluginID) + }) } func TestReadingNavigationSettings(t *testing.T) { From 3dfa49b37654469a53952934c10317ab781636ea Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 3 Nov 2022 16:35:20 -0700 Subject: [PATCH 027/926] Playlist: cleanup object store implementation (#58201) --- pkg/services/export/object_store.go | 4 +- .../playlist/playlistimpl/object_store.go | 47 ++++++++++--------- pkg/services/store/auth.go | 6 +++ public/app/features/playlist/PlaylistForm.tsx | 17 +++++-- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/pkg/services/export/object_store.go b/pkg/services/export/object_store.go index 360021ed8ea..990c4aca0c3 100644 --- a/pkg/services/export/object_store.go +++ b/pkg/services/export/object_store.go @@ -113,6 +113,8 @@ func (e *objectStoreJob) start() { e.status.Status = "error: " + err.Error() return } + e.status.Last = fmt.Sprintf("export %d dashboards", len(dashInfo)) + e.broadcaster(e.status) for _, dash := range dashInfo { rowUser.OrgID = dash.OrgID @@ -261,7 +263,7 @@ func (e *objectStoreJob) getDashboards(ctx context.Context) ([]dashInfo, error) e.broadcaster(e.status) dash := make([]dashInfo, 0) - rows, err := e.sess.Query(ctx, "SELECT org_id,uid,data,updated_by FROM dashboard WHERE is_folder=0") + rows, err := e.sess.Query(ctx, "SELECT org_id,uid,data,updated_by FROM dashboard WHERE is_folder=false") if err != nil { return nil, err } diff --git a/pkg/services/playlist/playlistimpl/object_store.go b/pkg/services/playlist/playlistimpl/object_store.go index 45a3903eec1..ad7b867c0a0 100644 --- a/pkg/services/playlist/playlistimpl/object_store.go +++ b/pkg/services/playlist/playlistimpl/object_store.go @@ -27,7 +27,12 @@ type objectStoreImpl struct { var _ playlist.Service = &objectStoreImpl{} func (s *objectStoreImpl) sync() { - rows, err := s.sess.Query(context.Background(), "SELECT org_id,uid FROM playlist ORDER BY org_id asc") + type Info struct { + OrgID int64 `db:"org_id"` + UID string `db:"uid"` + } + results := []Info{} + err := s.sess.Select(context.Background(), &results, "SELECT org_id,uid FROM playlist ORDER BY org_id asc") if err != nil { fmt.Printf("error loading playlists") return @@ -35,22 +40,15 @@ func (s *objectStoreImpl) sync() { // Change the org_id with each row rowUser := &user.SignedInUser{ - Login: "?", - OrgID: 0, // gets filled in from each row - UserID: 0, + OrgID: 0, // gets filled in from each row + UserID: 0, // Admin user + IsGrafanaAdmin: true, } ctx := objectstore.ContextWithUser(context.Background(), rowUser) - uid := "" - for rows.Next() { - err = rows.Scan(&rowUser.OrgID, &uid) - if err != nil { - fmt.Printf("error loading playlists: %v", err) - return - } - + for _, info := range results { dto, err := s.sqlimpl.Get(ctx, &playlist.GetPlaylistByUidQuery{ - OrgId: rowUser.OrgID, - UID: uid, + OrgId: info.OrgID, + UID: info.UID, }) if err != nil { fmt.Printf("error loading playlist: %v", err) @@ -59,8 +57,10 @@ func (s *objectStoreImpl) sync() { body, _ := json.Marshal(dto) _, _ = s.objectstore.Write(ctx, &object.WriteObjectRequest{ GRN: &object.GRN{ - UID: uid, - Kind: models.StandardKindPlaylist, + TenantId: info.OrgID, + UID: info.UID, + Kind: models.StandardKindPlaylist, + Scope: models.ObjectStoreScopeEntity, }, Body: body, }) @@ -98,8 +98,9 @@ func (s *objectStoreImpl) Update(ctx context.Context, cmd *playlist.UpdatePlayli } _, err = s.objectstore.Write(ctx, &object.WriteObjectRequest{ GRN: &object.GRN{ - UID: rsp.Uid, - Kind: models.StandardKindPlaylist, + UID: rsp.Uid, + Kind: models.StandardKindPlaylist, + Scope: models.ObjectStoreScopeEntity, }, Body: body, }) @@ -115,8 +116,9 @@ func (s *objectStoreImpl) Delete(ctx context.Context, cmd *playlist.DeletePlayli if err == nil { _, err = s.objectstore.Delete(ctx, &object.DeleteObjectRequest{ GRN: &object.GRN{ - UID: cmd.UID, - Kind: models.StandardKindPlaylist, + UID: cmd.UID, + Kind: models.StandardKindPlaylist, + Scope: models.ObjectStoreScopeEntity, }, }) if err != nil { @@ -146,8 +148,9 @@ func (s *objectStoreImpl) GetWithoutItems(ctx context.Context, q *playlist.GetPl func (s *objectStoreImpl) Get(ctx context.Context, q *playlist.GetPlaylistByUidQuery) (*playlist.PlaylistDTO, error) { rsp, err := s.objectstore.Read(ctx, &object.ReadObjectRequest{ GRN: &object.GRN{ - UID: q.UID, - Kind: models.StandardKindPlaylist, + UID: q.UID, + Kind: models.StandardKindPlaylist, + Scope: models.ObjectStoreScopeEntity, }, WithBody: true, }) diff --git a/pkg/services/store/auth.go b/pkg/services/store/auth.go index d7258280f89..b9d7cb8bf9b 100644 --- a/pkg/services/store/auth.go +++ b/pkg/services/store/auth.go @@ -44,6 +44,12 @@ func GetUserIDString(user *user.SignedInUser) string { if user == nil { return "" } + if user.IsAnonymous { + return "anon" + } + if user.ApiKeyID > 0 { + return fmt.Sprintf("key:%d", user.UserID) + } if user.IsRealUser() { return fmt.Sprintf("user:%d:%s", user.UserID, user.Login) } diff --git a/public/app/features/playlist/PlaylistForm.tsx b/public/app/features/playlist/PlaylistForm.tsx index c930acf4ef1..9e667d5183f 100644 --- a/public/app/features/playlist/PlaylistForm.tsx +++ b/public/app/features/playlist/PlaylistForm.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; @@ -18,6 +18,7 @@ interface Props { } export const PlaylistForm = ({ onSubmit, playlist }: Props) => { + const [saving, setSaving] = useState(false); const { name, interval, items: propItems } = playlist; const tagOptions = useMemo(() => { return () => getGrafanaSearcher().tags({ kind: ['dashboard'] }); @@ -25,9 +26,14 @@ export const PlaylistForm = ({ onSubmit, playlist }: Props) => { const { items, addById, addByTag, deleteItem, moveItem } = usePlaylistItems(propItems); + const doSubmit = (list: Playlist) => { + setSaving(true); + onSubmit({ ...list, items }); + }; + return (
-
onSubmit({ ...list, items })} validateOn={'onBlur'}> + {({ register, errors }) => { const isDisabled = items.length === 0 || Object.keys(errors).length > 0; return ( @@ -73,7 +79,12 @@ export const PlaylistForm = ({ onSubmit, playlist }: Props) => {
- From d131733f550e97058d8fd31751f2f27c7f24415c Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri, 4 Nov 2022 11:04:24 +0200 Subject: [PATCH 028/926] Nested Folder: Modify store Update() (#58183) * Nested Folder: Modify store Update() * fixup --- pkg/services/folder/folderimpl/sqlstore.go | 36 +++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 2f2eae569a2..52a2a6ffbde 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -96,23 +96,41 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) } cmd.Folder.Updated = time.Now() + existingUID := cmd.Folder.UID err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - description := cmd.Folder.Description + sql := strings.Builder{} + sql.Write([]byte("UPDATE folder SET ")) + columnsToUpdate := []string{"updated = ?"} + args := []interface{}{cmd.Folder.Updated} if cmd.NewDescription != nil { - description = *cmd.NewDescription + columnsToUpdate = append(columnsToUpdate, "description = ?") + cmd.Folder.Description = *cmd.NewDescription + args = append(args, cmd.Folder.Description) } - title := cmd.Folder.Title if cmd.NewTitle != nil { - title = *cmd.NewTitle + columnsToUpdate = append(columnsToUpdate, "title = ?") + cmd.Folder.Title = *cmd.NewTitle + args = append(args, cmd.Folder.Title) } - uid := cmd.Folder.UID if cmd.NewUID != nil { - uid = *cmd.NewUID + columnsToUpdate = append(columnsToUpdate, "uid = ?") + cmd.Folder.UID = *cmd.NewUID + args = append(args, cmd.Folder.UID) } - res, err := sess.Exec("UPDATE folder SET description = ?, title = ?, uid = ?, updated = ? WHERE uid = ? AND org_id = ?", description, title, uid, cmd.Folder.Updated, cmd.Folder.UID, cmd.Folder.OrgID) + if len(columnsToUpdate) == 0 { + return folder.ErrBadRequest.Errorf("no columns to update") + } + + sql.Write([]byte(strings.Join(columnsToUpdate, ", "))) + sql.Write([]byte(" WHERE uid = ? AND org_id = ?")) + args = append(args, existingUID, cmd.Folder.OrgID) + + args = append([]interface{}{sql.String()}, args...) + + res, err := sess.Exec(args...) if err != nil { return folder.ErrDatabaseError.Errorf("failed to update folder: %w", err) } @@ -124,10 +142,6 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) if affected == 0 { return folder.ErrInternal.Errorf("no folders are updated") } - - cmd.Folder.Description = description - cmd.Folder.Title = title - cmd.Folder.UID = uid return nil }) From 871d98e55030d14c05c5fc396b38060b81953db7 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 4 Nov 2022 09:20:03 +0000 Subject: [PATCH 029/926] hopefully improve slate test reliability (#58171) --- e2e/various-suite/slate.spec.ts | 37 ++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/e2e/various-suite/slate.spec.ts b/e2e/various-suite/slate.spec.ts index ae13d14385f..26f8087fbfa 100644 --- a/e2e/various-suite/slate.spec.ts +++ b/e2e/various-suite/slate.spec.ts @@ -45,58 +45,65 @@ describe('Loki slate editor', () => { e2e().contains('Code').click(); const queryField = e2e().get('.slate-query-field'); queryField.type('time('); - queryField.then(($el) => { + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.eq('time()'); }); // removes closing brace when opening brace is removed queryField.type('{backspace}'); - queryField.then(($el) => { + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.eq('time'); }); // keeps closing brace when opening brace is removed and inner values exist - queryField.type(`{selectall}{backspace}time(test{leftArrow}{leftArrow}{leftArrow}{leftArrow}{backspace}`); - queryField.then(($el) => { + queryField.clear(); + queryField.type('time(test{leftArrow}{leftArrow}{leftArrow}{leftArrow}{backspace}'); + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.eq('timetest)'); }); // overrides an automatically inserted brace - queryField.type(`{selectall}{backspace}time()`); - queryField.then(($el) => { + queryField.clear(); + queryField.type('time()'); + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.eq('time()'); }); // does not override manually inserted braces - queryField.type(`{selectall}{backspace}))`); - queryField.then(($el) => { + queryField.clear(); + queryField.type('))'); + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.eq('))'); }); /** Clear Plugin */ //does not change the empty value - queryField.type(`{selectall}{backspace}{ctrl+k}`); - queryField.then(($el) => { + queryField.clear(); + queryField.type('{ctrl+k}'); + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.match(/Enter a Loki query/); }); // clears to the end of the line - queryField.type(`{selectall}{backspace}foo{leftArrow}{leftArrow}{leftArrow}{ctrl+k}`); - queryField.then(($el) => { + queryField.clear(); + queryField.type('foo{leftArrow}{leftArrow}{leftArrow}{ctrl+k}'); + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.match(/Enter a Loki query/); }); // clears from the middle to the end of the line - queryField.type(`{selectall}{backspace}foo bar{leftArrow}{leftArrow}{leftArrow}{leftArrow}{ctrl+k}`); - queryField.then(($el) => { + queryField.clear(); + queryField.type('foo bar{leftArrow}{leftArrow}{leftArrow}{leftArrow}{ctrl+k}'); + queryField.should(($el) => { expect($el.text().replace(/\uFEFF/g, '')).to.eq('foo'); }); /** Runner plugin */ //should execute query when enter with shift is pressed - queryField.type(`{selectall}{backspace}{shift+enter}`); + queryField.clear(); + queryField.type('{shift+enter}'); e2e().get('[data-testid="explore-no-data"]').should('be.visible'); /** Suggestions plugin */ From 17f7cbf0f61ddd7a17e2df7fa2412c28b6f3f4eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 09:20:27 +0000 Subject: [PATCH 030/926] Update dependency babel-loader to v9.1.0 (#58155) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- devenv/docker/loadtest-ts/package.json | 2 +- devenv/docker/loadtest-ts/yarn.lock | 10 +++++----- package.json | 2 +- packages/grafana-e2e/package.json | 2 +- yarn.lock | 12 ++++++------ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/devenv/docker/loadtest-ts/package.json b/devenv/docker/loadtest-ts/package.json index b764bd60c78..196722fd7ef 100644 --- a/devenv/docker/loadtest-ts/package.json +++ b/devenv/docker/loadtest-ts/package.json @@ -12,7 +12,7 @@ "@types/k6": "0.39.1", "@types/shortid": "0.0.29", "@types/webpack": "5.28.0", - "babel-loader": "9.0.1", + "babel-loader": "9.1.0", "shortid": "2.2.16", "ts-node": "10.9.1", "typescript": "4.8.4", diff --git a/devenv/docker/loadtest-ts/yarn.lock b/devenv/docker/loadtest-ts/yarn.lock index 6e060c5179e..cc3886e5241 100644 --- a/devenv/docker/loadtest-ts/yarn.lock +++ b/devenv/docker/loadtest-ts/yarn.lock @@ -1365,7 +1365,7 @@ __metadata: "@types/k6": 0.39.1 "@types/shortid": 0.0.29 "@types/webpack": 5.28.0 - babel-loader: 9.0.1 + babel-loader: 9.1.0 shortid: 2.2.16 ts-node: 10.9.1 typescript: 4.8.4 @@ -1845,16 +1845,16 @@ __metadata: languageName: node linkType: hard -"babel-loader@npm:9.0.1": - version: 9.0.1 - resolution: "babel-loader@npm:9.0.1" +"babel-loader@npm:9.1.0": + version: 9.1.0 + resolution: "babel-loader@npm:9.1.0" dependencies: find-cache-dir: ^3.3.2 schema-utils: ^4.0.0 peerDependencies: "@babel/core": ^7.12.0 webpack: ">=5" - checksum: 28164515105c54ab89c72a51fdad7076d40cf871df94e37a3fcbce6c69410f3f03ec93e83148e7ce1839d4e86c0f7be6e81b3eeb9f1a4cb52fcc78af6a438ef6 + checksum: 774758febd1e8ca804abcae3b8f65634330dc688837424d0946f06d1386914de43435cce691710fa144eccdf1292cf883439ac3598ce7320916acfaaa2372641 languageName: node linkType: hard diff --git a/package.json b/package.json index 123f20e53d3..a63bb18a42d 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ "@wojtekmaj/enzyme-adapter-react-17": "0.6.7", "autoprefixer": "10.4.13", "babel-jest": "28.1.3", - "babel-loader": "9.0.1", + "babel-loader": "9.1.0", "babel-plugin-angularjs-annotate": "0.10.0", "babel-plugin-macros": "3.1.0", "blob-polyfill": "7.0.20220408", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index cae52c33a49..b0dffe2fb8b 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -64,7 +64,7 @@ "@grafana/e2e-selectors": "9.3.0-pre", "@grafana/tsconfig": "^1.2.0-rc1", "@mochajs/json-file-reporter": "^1.2.0", - "babel-loader": "9.0.1", + "babel-loader": "9.1.0", "blink-diff": "1.0.13", "chrome-remote-interface": "0.31.3", "commander": "8.3.0", diff --git a/yarn.lock b/yarn.lock index 033eb1004d4..dba3e878e41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4419,7 +4419,7 @@ __metadata: "@types/lodash": 4.14.187 "@types/node": 16.11.45 "@types/uuid": 8.3.4 - babel-loader: 9.0.1 + babel-loader: 9.1.0 blink-diff: 1.0.13 chrome-remote-interface: 0.31.3 commander: 8.3.0 @@ -13975,16 +13975,16 @@ __metadata: languageName: node linkType: hard -"babel-loader@npm:9.0.1": - version: 9.0.1 - resolution: "babel-loader@npm:9.0.1" +"babel-loader@npm:9.1.0": + version: 9.1.0 + resolution: "babel-loader@npm:9.1.0" dependencies: find-cache-dir: ^3.3.2 schema-utils: ^4.0.0 peerDependencies: "@babel/core": ^7.12.0 webpack: ">=5" - checksum: 28164515105c54ab89c72a51fdad7076d40cf871df94e37a3fcbce6c69410f3f03ec93e83148e7ce1839d4e86c0f7be6e81b3eeb9f1a4cb52fcc78af6a438ef6 + checksum: 774758febd1e8ca804abcae3b8f65634330dc688837424d0946f06d1386914de43435cce691710fa144eccdf1292cf883439ac3598ce7320916acfaaa2372641 languageName: node linkType: hard @@ -21646,7 +21646,7 @@ __metadata: app: "link:./public/app" autoprefixer: 10.4.13 babel-jest: 28.1.3 - babel-loader: 9.0.1 + babel-loader: 9.1.0 babel-plugin-angularjs-annotate: 0.10.0 babel-plugin-macros: 3.1.0 baron: 3.0.3 From e3ea7ee1455a4f88c0c8bed8e5a1884aa658679c Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 4 Nov 2022 09:43:38 +0000 Subject: [PATCH 031/926] Doc: Add groups mapping config to readme (#58208) --- devenv/docker/blocks/auth/oauth/readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/devenv/docker/blocks/auth/oauth/readme.md b/devenv/docker/blocks/auth/oauth/readme.md index 622a4c890fb..c2a8b34ccf6 100644 --- a/devenv/docker/blocks/auth/oauth/readme.md +++ b/devenv/docker/blocks/auth/oauth/readme.md @@ -11,7 +11,7 @@ Here is the conf you need to add to your configuration file (conf/custom.ini): ```ini [auth] -signout_redirect_url = http://localhost:8087/auth/realms/grafana/protocol/openid-connect/logout?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin +signout_redirect_url = http://localhost:8087/realms/grafana/protocol/openid-connect/logout?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin [auth.generic_oauth] enabled = true @@ -23,6 +23,7 @@ scopes = openid email profile offline_access roles email_attribute_path = email login_attribute_path = username name_attribute_path = full_name +groups_attribute_path = groups auth_url = http://localhost:8087/realms/grafana/protocol/openid-connect/auth token_url = http://localhost:8087/realms/grafana/protocol/openid-connect/token role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' From 3f38c95377003cf64315dd3dbd0569316c6684a0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 10:42:45 +0000 Subject: [PATCH 032/926] Update dependency @types/k6 to v0.41.0 (#58165) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- devenv/docker/loadtest-ts/package.json | 2 +- devenv/docker/loadtest-ts/yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/devenv/docker/loadtest-ts/package.json b/devenv/docker/loadtest-ts/package.json index 196722fd7ef..3c2a8d96d00 100644 --- a/devenv/docker/loadtest-ts/package.json +++ b/devenv/docker/loadtest-ts/package.json @@ -9,7 +9,7 @@ "@babel/plugin-proposal-object-rest-spread": "7.19.4", "@babel/preset-env": "7.19.4", "@babel/preset-typescript": "7.18.6", - "@types/k6": "0.39.1", + "@types/k6": "0.41.0", "@types/shortid": "0.0.29", "@types/webpack": "5.28.0", "babel-loader": "9.1.0", diff --git a/devenv/docker/loadtest-ts/yarn.lock b/devenv/docker/loadtest-ts/yarn.lock index cc3886e5241..6e86007c1a1 100644 --- a/devenv/docker/loadtest-ts/yarn.lock +++ b/devenv/docker/loadtest-ts/yarn.lock @@ -1362,7 +1362,7 @@ __metadata: "@babel/plugin-proposal-object-rest-spread": 7.19.4 "@babel/preset-env": 7.19.4 "@babel/preset-typescript": 7.18.6 - "@types/k6": 0.39.1 + "@types/k6": 0.41.0 "@types/shortid": 0.0.29 "@types/webpack": 5.28.0 babel-loader: 9.1.0 @@ -1516,10 +1516,10 @@ __metadata: languageName: node linkType: hard -"@types/k6@npm:0.39.1": - version: 0.39.1 - resolution: "@types/k6@npm:0.39.1" - checksum: 7625b38b5bf9a101e37f825a6dd87e574d69b175ac2d8eb2975e9a14bf1ae1fbaa4152086b08a8d3e14a60359dd62b93cd439db0852ad33d03753cdd2ffc3489 +"@types/k6@npm:0.41.0": + version: 0.41.0 + resolution: "@types/k6@npm:0.41.0" + checksum: efc027b5967f8fa1102eb7d0e4867d90bdbdec32153507da510827ccdc5a7a7fefac30703cfd31a8ad5550313a63a5948a369f7503490e9851f19c347bd531b5 languageName: node linkType: hard From 1e7d9c6360b0e02f4f6c65a4904c20684605da49 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 10:44:00 +0000 Subject: [PATCH 033/926] Update dependency sass to v1.56.0 (#58207) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a63bb18a42d..0f45f31e8ab 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,7 @@ "redux-mock-store": "1.5.4", "rimraf": "3.0.2", "rudder-sdk-js": "2.18.1", - "sass": "1.55.0", + "sass": "1.56.0", "sass-loader": "13.1.0", "sinon": "14.0.1", "style-loader": "3.3.1", diff --git a/yarn.lock b/yarn.lock index dba3e878e41..96a3a76a55c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21787,7 +21787,7 @@ __metadata: rst2html: "github:thoward/rst2html#990cb89f2a300cdd9151790be377c4c0840df809" rudder-sdk-js: 2.18.1 rxjs: 7.5.7 - sass: 1.55.0 + sass: 1.56.0 sass-loader: 13.1.0 selecto: 1.20.2 semver: 7.3.8 @@ -34234,16 +34234,16 @@ __metadata: languageName: node linkType: hard -"sass@npm:1.55.0": - version: 1.55.0 - resolution: "sass@npm:1.55.0" +"sass@npm:1.56.0": + version: 1.56.0 + resolution: "sass@npm:1.56.0" dependencies: chokidar: ">=3.0.0 <4.0.0" immutable: ^4.0.0 source-map-js: ">=0.6.2 <2.0.0" bin: sass: sass.js - checksum: 7d769ed08efce4e6134e0f3dc11c4f07e32c413ac8eb43c5855f2686890fdcbd80da34165c91fb4ba407f478ca108e171574b5a60cb9814a5ed09d80f6014f96 + checksum: 37fb48b838f7a12f3c3efbf27bfc5f2b7fba015ed4b11effe32bd9488e30e1d5cefcbfef1e5c5dbd95557889fe2c7ec72f33e898cfc76182ea34eae03b1a4fb1 languageName: node linkType: hard From aa9039a8414d75a2a5cdda953dd7437bacdc1d04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 10:46:19 +0000 Subject: [PATCH 034/926] Update dependency i18next to v22 (#58156) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0f45f31e8ab..a4c9207d9ab 100644 --- a/package.json +++ b/package.json @@ -321,7 +321,7 @@ "framework-utils": "^1.1.0", "history": "4.10.1", "hoist-non-react-statics": "3.3.2", - "i18next": "^21.9.2", + "i18next": "^22.0.0", "immer": "9.0.16", "immutable": "4.1.0", "jquery": "3.6.1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 55e7802742b..a8cb495fc3e 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -68,7 +68,7 @@ "d3": "5.15.0", "date-fns": "2.29.3", "hoist-non-react-statics": "3.3.2", - "i18next": "^21.9.2", + "i18next": "^22.0.0", "immutable": "4.1.0", "is-hotkey": "0.2.0", "jquery": "3.6.1", diff --git a/yarn.lock b/yarn.lock index 96a3a76a55c..361cf87e574 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4764,7 +4764,7 @@ __metadata: esbuild: 0.15.12 expose-loader: 4.0.0 hoist-non-react-statics: 3.3.2 - i18next: ^21.9.2 + i18next: ^22.0.0 immutable: 4.1.0 is-hotkey: 0.2.0 jquery: 3.6.1 @@ -21696,7 +21696,7 @@ __metadata: html-webpack-plugin: 5.5.0 http-server: 14.1.1 husky: 8.0.1 - i18next: ^21.9.2 + i18next: ^22.0.0 i18next-parser: 6.6.0 immer: 9.0.16 immutable: 4.1.0 @@ -22713,7 +22713,7 @@ __metadata: languageName: node linkType: hard -"i18next@npm:^21.2.0, i18next@npm:^21.9.2": +"i18next@npm:^21.2.0": version: 21.9.2 resolution: "i18next@npm:21.9.2" dependencies: @@ -22722,6 +22722,15 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^22.0.0": + version: 22.0.4 + resolution: "i18next@npm:22.0.4" + dependencies: + "@babel/runtime": ^7.17.2 + checksum: aa49e6e48583833ee673c0dd9568081a5e15fde9549aa3e47a99ad91d3fdb7469f8a315864fc45ec466b1e60caa00d5fc8371e47840068048b4ab79a69dc2b19 + languageName: node + linkType: hard + "iconv-lite@npm:0.4, iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24, iconv-lite@npm:^0.4.4, iconv-lite@npm:^0.4.8": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" From 7bb76e0975b5553f3e727c19f3015a138b226f41 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Fri, 4 Nov 2022 10:47:54 +0000 Subject: [PATCH 035/926] Azure Monitor: E2E Tests (#54619) * Update credentials form with data-testids and aria-labels * Update gitignore * Add example dashboard * Stub out E2E test for creating ds and importing dashboard * Add component selectors * Remove subscription check temporarily * Appropriately set disabled prop * Fix lint issues * Update to use selectors and wait on subscriptions request * Add test for metrics panel - Add required selectors for resource picker and metrics query editor * Add logs and ARG basic query scenarios - More selector updates * Add E2E test for template variables - Tests advanced resource picker - Adds required selectors * Remove log and add annotation e2e test * Update test * Prettier/betterer updates - Remove gitignore change * Lint issues * Update betterer results * Lint issue and remove unneeded import * Don't print certain commands * Avoiding flakiness - Ensure code editor has sufficient time to load in ARG test - Avoid flakiness around correct template variable being selected by typing in resource name * Remove be.visible requirement * Update test * Update selector name * Reuse datasource * Fix datasource reuse and combine query tests * Remove import dashboard step as unneeded * Review - Randomise datasource name - Skip annotations test - Remove unused example dashboard * Update to ensure e2e test works in CI * Update e2e test - Update environment variables (process is not available in cypress) - Add wait on resource picker searches to avoid flakiness - Update subscription and resource group names * Update CODEOWNERS * Parse credentials in CI from outputs file * Update outputs file path * Fix selector * Undo selector change * Update e2e tests - Set default subscription - Fix datasource selection in variable editor - Fix resource picker search flakiness - Set subscription in ARG query test - Fix resource group selection - Update resource group * Review * Review 2 --- .github/CODEOWNERS | 1 + e2e/cloud-plugins-suite/azure-monitor.spec.ts | 298 ++++++++++++++++++ .../grafana-e2e/src/flows/configurePanel.ts | 2 +- .../ArgQueryEditor/ArgQueryEditor.test.tsx | 21 +- .../ArgQueryEditor/ArgQueryEditor.tsx | 3 +- .../components/AzureCredentialsForm.tsx | 158 +++++----- .../LogsQueryEditor/FormatAsField.tsx | 3 +- .../MetricsQueryEditor/MetricNameField.tsx | 3 +- .../QueryEditor/QueryEditor.test.tsx | 4 +- .../components/QueryHeader.tsx | 3 +- .../ResourceField/ResourceField.tsx | 5 +- .../components/ResourcePicker/Advanced.tsx | 7 +- .../ResourcePicker/ResourcePicker.tsx | 7 +- .../components/ResourcePicker/Search.tsx | 3 + .../components/SubscriptionField.tsx | 5 +- .../VariableEditor/VariableEditor.tsx | 31 +- .../e2e/selectors.ts | 100 ++++++ 17 files changed, 561 insertions(+), 93 deletions(-) create mode 100644 e2e/cloud-plugins-suite/azure-monitor.spec.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/e2e/selectors.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 69fa184587b..78df7648155 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -118,6 +118,7 @@ go.sum @grafana/backend-platform /public/locales @grafana/user-essentials /public/app/core/internationalization @grafana/user-essentials /e2e @grafana/user-essentials +/e2e/cloud-plugins-suite @grafana/cloud-provider-plugins /packages @grafana/user-essentials @grafana/plugins-platform-frontend @grafana/grafana-bi-squad /packages/grafana-e2e-selectors @grafana/user-essentials /packages/grafana-e2e @grafana/user-essentials diff --git a/e2e/cloud-plugins-suite/azure-monitor.spec.ts b/e2e/cloud-plugins-suite/azure-monitor.spec.ts new file mode 100644 index 00000000000..db8fc29c235 --- /dev/null +++ b/e2e/cloud-plugins-suite/azure-monitor.spec.ts @@ -0,0 +1,298 @@ +import { load } from 'js-yaml'; +import { v4 as uuidv4 } from 'uuid'; + +import { e2e } from '@grafana/e2e'; + +import { selectors } from '../../public/app/plugins/datasource/grafana-azure-monitor-datasource/e2e/selectors'; +import { + AzureDataSourceJsonData, + AzureDataSourceSecureJsonData, + AzureQueryType, +} from '../../public/app/plugins/datasource/grafana-azure-monitor-datasource/types'; + +const provisioningPath = `../../provisioning/datasources/azmonitor-ds.yaml`; +const e2eSelectors = e2e.getSelectors(selectors.components); + +type AzureMonitorConfig = { + secureJsonData: AzureDataSourceSecureJsonData; + jsonData: AzureDataSourceJsonData; +}; + +type AzureMonitorProvision = { datasources: AzureMonitorConfig[] }; + +const dataSourceName = `Azure Monitor E2E Tests - ${uuidv4()}`; + +function provisionAzureMonitorDatasources(datasources: AzureMonitorProvision[]) { + const datasource = datasources[0].datasources[0]; + + e2e() + .intercept(/subscriptions/) + .as('subscriptions'); + + e2e.flows.addDataSource({ + type: 'Azure Monitor', + name: dataSourceName, + form: () => { + e2eSelectors.configEditor.azureCloud.input().find('input').type('Azure').type('{enter}'); + // We set the log value to false here to ensure that secrets aren't printed to logs + e2eSelectors.configEditor.tenantID.input().find('input').type(datasource.jsonData.tenantId, { log: false }); + e2eSelectors.configEditor.clientID.input().find('input').type(datasource.jsonData.clientId, { log: false }); + e2eSelectors.configEditor.clientSecret + .input() + .find('input') + .type(datasource.secureJsonData.clientSecret, { log: false }); + e2eSelectors.configEditor.loadSubscriptions.button().click().wait('@subscriptions').wait(500); + e2eSelectors.configEditor.defaultSubscription.input().find('input').type('datasources{enter}'); + }, + expectedAlertMessage: 'Successfully connected to all Azure Monitor endpoints', + }); +} + +const addAzureMonitorVariable = ( + name: string, + type: AzureQueryType, + isFirst: boolean, + options?: { subscription?: string; resourceGroup?: string; namespace?: string; resource?: string } +) => { + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.components.Tab.title('Variables').click(); + if (isFirst) { + e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click(); + } else { + e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); + } + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name); + e2e.components.DataSourcePicker.inputV2().type(`${dataSourceName}{enter}`); + e2eSelectors.variableEditor.queryType + .input() + .find('input') + .type(`${type.replace('Azure', '').trim()}{enter}`); + switch (type) { + case AzureQueryType.ResourceGroupsQuery: + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + break; + case AzureQueryType.NamespacesQuery: + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.resourceGroup.input().find('input').type(`${options?.resourceGroup}{enter}`); + break; + case AzureQueryType.ResourceNamesQuery: + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.resourceGroup.input().find('input').type(`${options?.resourceGroup}{enter}`); + e2eSelectors.variableEditor.namespace.input().find('input').type(`${options?.namespace}{enter}`); + break; + case AzureQueryType.MetricNamesQuery: + e2eSelectors.variableEditor.subscription.input().find('input').type(`${options?.subscription}{enter}`); + e2eSelectors.variableEditor.resourceGroup.input().find('input').type(`${options?.resourceGroup}{enter}`); + e2eSelectors.variableEditor.namespace.input().find('input').type(`${options?.namespace}{enter}`); + e2eSelectors.variableEditor.resource.input().find('input').type(`${options?.resource}{enter}`); + break; + } + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.components.PageToolbar.item('Go Back').click(); +}; + +e2e.scenario({ + describeName: 'Add Azure Monitor datasource', + itName: 'fills out datasource connection configuration', + scenario: () => { + // This variable will be set in CI + const CI = e2e.env('CI'); + if (CI) { + e2e() + .readFile('../../outputs.json') + .then((outputs) => { + provisionAzureMonitorDatasources([ + { + datasources: [ + { + jsonData: { + cloudName: 'Azure', + tenantId: outputs.tenantId, + clientId: outputs.clientId, + }, + secureJsonData: { clientSecret: outputs.clientSecret }, + }, + ], + }, + ]); + }); + } else { + e2e() + .readFile(provisioningPath) + .then((azMonitorProvision: string) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const yaml = load(azMonitorProvision) as AzureMonitorProvision; + provisionAzureMonitorDatasources([yaml]); + }); + } + e2e.setScenarioContext({ addedDataSources: [] }); + }, +}); + +e2e.scenario({ + describeName: 'Create dashboard and add a panel for each query type', + itName: 'create dashboard, add panel for metrics query, log analytics query, and ARG query', + scenario: () => { + e2e.flows.addDashboard({ + timeRange: { + from: '2022-10-03 00:00:00', + to: '2022-10-03 23:59:59', + zone: 'Coordinated Universal Time', + }, + }); + e2e.flows.addPanel({ + dataSourceName, + visitDashboardAtStart: false, + queriesForm: () => { + e2eSelectors.queryEditor.resourcePicker.select.button().click(); + e2eSelectors.queryEditor.resourcePicker.search + .input() + .wait(100) + .type('azmonmetricstest') + .wait(500) + .type('{enter}'); + e2e().contains('azmonmetricstest').click(); + e2eSelectors.queryEditor.resourcePicker.apply.button().click(); + e2e().contains('microsoft.storage/storageaccounts'); + e2eSelectors.queryEditor.metricsQueryEditor.metricName.input().find('input').type('Used capacity{enter}'); + }, + }); + e2e.components.PanelEditor.applyButton().click(); + e2e.flows.addPanel({ + dataSourceName, + visitDashboardAtStart: false, + queriesForm: () => { + e2eSelectors.queryEditor.header.select().find('input').type('Logs{enter}'); + e2eSelectors.queryEditor.resourcePicker.select.button().click(); + e2eSelectors.queryEditor.resourcePicker.search + .input() + .wait(100) + .type('azmonlogstest') + .wait(500) + .type('{enter}'); + e2e().contains('azmonlogstest').click(); + e2eSelectors.queryEditor.resourcePicker.apply.button().click(); + e2e.components.CodeEditor.container().type('AzureDiagnostics'); + e2eSelectors.queryEditor.logsQueryEditor.formatSelection.input().type('Time series{enter}'); + }, + }); + e2e.components.PanelEditor.applyButton().click(); + e2e.flows.addPanel({ + dataSourceName, + visitDashboardAtStart: false, + queriesForm: () => { + e2eSelectors.queryEditor.header.select().find('input').type('Azure Resource Graph{enter}'); + e2e().wait(1000); // Need to wait for code editor to completely load + e2e().get('[aria-label="Remove Primary Subscription"]').click(); + e2eSelectors.queryEditor.argsQueryEditor.subscriptions.input().find('input').type('datasources{enter}'); + e2e.components.CodeEditor.container().type( + "Resources | where resourceGroup == 'cloud-plugins-e2e-test' | project name, resourceGroup" + ); + e2e.components.PanelEditor.toggleTableView().click({ force: true }); + }, + }); + }, +}); + +e2e.scenario({ + describeName: 'Create dashboard with template variables', + itName: 'creates a dashboard that includes a template variable', + scenario: () => { + e2e.flows.addDashboard({ + timeRange: { + from: '2022-10-03 00:00:00', + to: '2022-10-03 23:59:59', + zone: 'Coordinated Universal Time', + }, + }); + addAzureMonitorVariable('subscription', AzureQueryType.SubscriptionsQuery, true); + addAzureMonitorVariable('resourceGroups', AzureQueryType.ResourceGroupsQuery, false, { + subscription: '$subscription', + }); + addAzureMonitorVariable('namespaces', AzureQueryType.NamespacesQuery, false, { + subscription: '$subscription', + resourceGroup: '$resourceGroups', + }); + addAzureMonitorVariable('resource', AzureQueryType.ResourceNamesQuery, false, { + subscription: '$subscription', + resourceGroup: '$resourceGroups', + namespace: '$namespace', + }); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('subscription').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('grafanalabs-datasources-dev').click(); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups').parent().find('button').click(); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups') + .parent() + .find('input') + .type('cloud-plugins-e2e-test{enter}'); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces').parent().find('button').click(); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces') + .parent() + .find('input') + .type('microsoft.storage/storageaccounts{enter}'); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource').parent().find('button').click(); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource').parent().find('input').type('azmonmetricstest{enter}'); + e2e.flows.addPanel({ + dataSourceName, + visitDashboardAtStart: false, + queriesForm: () => { + e2eSelectors.queryEditor.resourcePicker.select.button().click(); + e2eSelectors.queryEditor.resourcePicker.advanced.collapse().click(); + e2eSelectors.queryEditor.resourcePicker.advanced.subscription.input().find('input').type('$subscription'); + e2eSelectors.queryEditor.resourcePicker.advanced.resourceGroup.input().find('input').type('$resourceGroups'); + e2eSelectors.queryEditor.resourcePicker.advanced.namespace.input().find('input').type('$namespaces'); + e2eSelectors.queryEditor.resourcePicker.advanced.resource.input().find('input').type('$resource'); + e2eSelectors.queryEditor.resourcePicker.apply.button().click(); + e2eSelectors.queryEditor.metricsQueryEditor.metricName.input().find('input').type('Transactions{enter}'); + }, + }); + }, +}); + +e2e.scenario({ + describeName: 'Create dashboard with annotation', + itName: 'creates a dashboard that includes an annotation', + scenario: () => { + e2e.flows.addDashboard({ + timeRange: { + from: '2022-10-03 00:00:00', + to: '2022-10-03 23:59:59', + zone: 'Coordinated Universal Time', + }, + }); + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.components.Tab.title('Annotations').click(); + e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2().click(); + e2e.pages.Dashboard.Settings.Annotations.Settings.name().type('TestAnnotation'); + e2e.components.DataSourcePicker.inputV2().click().type(`${dataSourceName}{enter}`); + e2eSelectors.queryEditor.resourcePicker.select.button().click(); + e2eSelectors.queryEditor.resourcePicker.search.input().type('azmonmetricstest'); + e2e().contains('azmonmetricstest').click(); + e2eSelectors.queryEditor.resourcePicker.apply.button().click(); + e2e().contains('microsoft.storage/storageaccounts'); + e2eSelectors.queryEditor.metricsQueryEditor.metricName.input().find('input').type('Transactions{enter}'); + e2e().get('table').contains('text').parent().find('input').click().type('Transactions (number){enter}'); + e2e.components.PageToolbar.item('Go Back').click(); + e2e.flows.addPanel({ + dataSourceName, + visitDashboardAtStart: false, + queriesForm: () => { + e2eSelectors.queryEditor.resourcePicker.select.button().click(); + e2eSelectors.queryEditor.resourcePicker.search.input().type('azmonmetricstest'); + e2e().contains('azmonmetricstest').click(); + e2eSelectors.queryEditor.resourcePicker.apply.button().click(); + e2e().contains('microsoft.storage/storageaccounts'); + e2eSelectors.queryEditor.metricsQueryEditor.metricName.input().find('input').type('Used capacity{enter}'); + }, + }); + }, + skipScenario: true, +}); + +e2e.scenario({ + describeName: 'Remove datasource', + itName: 'remove azure monitor datasource', + scenario: () => { + e2e.flows.deleteDataSource({ name: dataSourceName, id: '', quick: true }); + }, +}); diff --git a/packages/grafana-e2e/src/flows/configurePanel.ts b/packages/grafana-e2e/src/flows/configurePanel.ts index 384bef3200c..d3aeb699629 100644 --- a/packages/grafana-e2e/src/flows/configurePanel.ts +++ b/packages/grafana-e2e/src/flows/configurePanel.ts @@ -148,7 +148,7 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC //e2e().wait('@chartData'); // Avoid annotations flakiness - e2e.components.RefreshPicker.runButtonV2().first().should('be.visible').click({ force: true }); + e2e.components.RefreshPicker.runButtonV2().first().click({ force: true }); e2e().wait('@chartData'); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.test.tsx index 838e9d5da1a..d9d223a69e9 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import createMockDatasource from '../../__mocks__/datasource'; import createMockQuery from '../../__mocks__/query'; +import { selectors } from '../../e2e/selectors'; import ArgQueryEditor from './ArgQueryEditor'; @@ -31,7 +32,9 @@ const defaultProps = { describe('ArgQueryEditor', () => { it('should render', async () => { render(); - expect(await screen.findByTestId('azure-monitor-arg-query-editor-with-experimental-ui')).toBeInTheDocument(); + expect( + await screen.findByTestId(selectors.components.queryEditor.argsQueryEditor.container.input) + ).toBeInTheDocument(); }); it('should select a subscription from the fetched array', async () => { @@ -40,7 +43,9 @@ describe('ArgQueryEditor', () => { }); const onChange = jest.fn(); render(); - expect(await screen.findByTestId('azure-monitor-arg-query-editor-with-experimental-ui')).toBeInTheDocument(); + expect( + await screen.findByTestId(selectors.components.queryEditor.argsQueryEditor.container.input) + ).toBeInTheDocument(); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ subscriptions: ['foo'] })); }); @@ -50,7 +55,9 @@ describe('ArgQueryEditor', () => { subscriptions: ['bar'], }); render(); - expect(await screen.findByTestId('azure-monitor-arg-query-editor-with-experimental-ui')).toBeInTheDocument(); + expect( + await screen.findByTestId(selectors.components.queryEditor.argsQueryEditor.container.input) + ).toBeInTheDocument(); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ subscriptions: ['bar'] })); }); @@ -63,7 +70,9 @@ describe('ArgQueryEditor', () => { subscriptions: ['bar'], }); render(); - expect(await screen.findByTestId('azure-monitor-arg-query-editor-with-experimental-ui')).toBeInTheDocument(); + expect( + await screen.findByTestId(selectors.components.queryEditor.argsQueryEditor.container.input) + ).toBeInTheDocument(); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ subscriptions: ['foo'] })); expect(onChange).not.toHaveBeenCalledWith(expect.objectContaining({ subscriptions: ['bar'] })); }); @@ -77,7 +86,9 @@ describe('ArgQueryEditor', () => { subscriptions: ['foo', 'bar', 'foobar'], }); render(); - expect(await screen.findByTestId('azure-monitor-arg-query-editor-with-experimental-ui')).toBeInTheDocument(); + expect( + await screen.findByTestId(selectors.components.queryEditor.argsQueryEditor.container.input) + ).toBeInTheDocument(); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ subscriptions: ['foo', 'bar'] })); expect(onChange).not.toHaveBeenCalledWith(expect.objectContaining({ subscriptions: ['foo', 'bar', 'foobar'] })); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.tsx index 780e0de3753..79aaec260f1 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ArgQueryEditor/ArgQueryEditor.tsx @@ -4,6 +4,7 @@ import React, { useState, useMemo } from 'react'; import { EditorFieldGroup, EditorRow, EditorRows } from '@grafana/experimental'; import Datasource from '../../datasource'; +import { selectors } from '../../e2e/selectors'; import { AzureMonitorErrorish, AzureMonitorOption, AzureMonitorQuery } from '../../types'; import SubscriptionField from '../SubscriptionField'; @@ -73,7 +74,7 @@ const ArgQueryEditor: React.FC = ({ }, [datasource]); return ( - + diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx index b5991492984..9bedcb7aa51 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx @@ -1,10 +1,12 @@ import React, { ChangeEvent, FunctionComponent, useEffect, useReducer, useState } from 'react'; import { SelectableValue } from '@grafana/data'; -import { InlineFormLabel, LegacyForms, Button, Select } from '@grafana/ui'; +import { InlineFormLabel, LegacyForms, Button, Select, InlineField } from '@grafana/ui'; import { isCredentialsComplete } from '../credentials'; +import { selectors } from '../e2e/selectors'; import { AzureAuthType, AzureCredentials } from '../types'; + const { Input } = LegacyForms; export interface Props { @@ -28,6 +30,8 @@ const authTypeOptions: Array> = [ }, ]; +const LABEL_WIDTH = 18; + export const AzureCredentialsForm: FunctionComponent = (props: Props) => { const { credentials, azureCloudOptions, onCredentialsChange, getSubscriptions, disabled } = props; const hasRequiredFields = isCredentialsComplete(credentials); @@ -153,68 +157,77 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => return (
{props.managedIdentityEnabled && ( -
-
- - Authentication - - opt.value === credentials.authType)} + options={authTypeOptions} + onChange={onAuthTypeChange} + disabled={disabled} + /> + )} {credentials.authType === 'clientsecret' && ( <> {azureCloudOptions && ( -
-
- - Azure Cloud - - opt.value === credentials.azureCloud)} + options={azureCloudOptions} + onChange={onAzureCloudChange} + /> + )} -
-
- Directory (tenant) ID -
- -
+ +
+
-
-
-
- Application (client) ID -
- -
+ + +
+
-
+ {!disabled && (typeof credentials.clientSecret === 'symbol' ? (
@@ -231,27 +244,28 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) =>
) : ( -
-
- Client Secret -
- -
-
-
+ + + ))} )} {getSubscriptions && ( <>
-
+
Default Subscription
= ({ metricNames, query, variab const options = useMemo(() => [...metricNames, variableOptionGroup], [metricNames, variableOptionGroup]); return ( - + setInternalSelected(r)} /> - diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/Search.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/Search.tsx index fdb633fc7fc..f8621841c3c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/Search.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/Search.tsx @@ -3,6 +3,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Icon, Input } from '@grafana/ui'; +import { selectors } from '../../e2e/selectors'; + const Search = ({ searchFn }: { searchFn: (searchPhrase: string) => void }) => { const [searchFilter, setSearchFilter] = useState(''); @@ -25,6 +27,7 @@ const Search = ({ searchFn }: { searchFn: (searchPhrase: string) => void }) => { debouncedSearch(searchPhrase); }} placeholder="search for a resource" + data-testid={selectors.components.queryEditor.resourcePicker.search.input} /> ); }; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx index b93082020f2..cb349249883 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/SubscriptionField.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; import { Select, MultiSelect } from '@grafana/ui'; +import { selectors } from '../e2e/selectors'; import { AzureMonitorQuery, AzureQueryEditorFieldProps, AzureMonitorOption, AzureQueryType } from '../types'; import { findOptions } from '../utils/common'; @@ -67,7 +68,7 @@ const SubscriptionField: React.FC = ({ const options = useMemo(() => [...subscriptions, variableOptionGroup], [subscriptions, variableOptionGroup]); return multiSelect ? ( - + = ({ /> ) : ( - + { )} {requireSubscription && ( - + { )} {(requireNamespace || hasNamespace) && ( - + } = { + components: components, +}; From e410dfbab80e9cbf894d228e0decd4e8ef7937e7 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 4 Nov 2022 11:58:17 +0100 Subject: [PATCH 036/926] Alerting: Encode path separators to side-step proxies (#58141) --- .../alerting/unified/utils/rule-id.test.ts | 50 ++++++++++++++++++- .../alerting/unified/utils/rule-id.ts | 21 +++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/public/app/features/alerting/unified/utils/rule-id.test.ts b/public/app/features/alerting/unified/utils/rule-id.test.ts index da4f98326b2..dc439fff81b 100644 --- a/public/app/features/alerting/unified/utils/rule-id.test.ts +++ b/public/app/features/alerting/unified/utils/rule-id.test.ts @@ -6,7 +6,7 @@ import { RulerRecordingRuleDTO, } from 'app/types/unified-alerting-dto'; -import { hashRulerRule } from './rule-id'; +import { hashRulerRule, parse, stringifyIdentifier } from './rule-id'; describe('hashRulerRule', () => { it('should not hash unknown rule types', () => { @@ -59,4 +59,52 @@ describe('hashRulerRule', () => { expect(hashRulerRule(grafanaRule)).toBe(RULE_UID); }); + + it('should correctly encode and decode unix-style path separators', () => { + const identifier = { + ruleSourceName: 'my-datasource', + namespace: 'folder1/folder2', + groupName: 'group1/group2', + ruleHash: 'abc123', + }; + + const encodedIdentifier = encodeURIComponent(stringifyIdentifier(identifier)); + + expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Ffolder2%24group1%1Fgroup2%24abc123'); + expect(encodedIdentifier).not.toContain('%2F'); + expect(parse(encodedIdentifier, true)).toStrictEqual(identifier); + }); + + it('should correctly decode regular encoded path separators (%2F)', () => { + const identifier = { + ruleSourceName: 'my-datasource', + namespace: 'folder1/folder2', + groupName: 'group1/group2', + ruleHash: 'abc123', + }; + + expect(parse('pri%24my-datasource%24folder1%2Ffolder2%24group1%2Fgroup2%24abc123', true)).toStrictEqual(identifier); + }); + + it('should correctly encode and decode windows-style path separators', () => { + const identifier = { + ruleSourceName: 'my-datasource', + namespace: 'folder1\\folder2', + groupName: 'group1\\group2', + ruleHash: 'abc123', + }; + + const encodedIdentifier = encodeURIComponent(stringifyIdentifier(identifier)); + + expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Efolder2%24group1%1Egroup2%24abc123'); + expect(parse(encodedIdentifier, true)).toStrictEqual(identifier); + }); + + it('should correctly decode a Grafana managed rule id', () => { + expect(parse('abc123', false)).toStrictEqual({ uid: 'abc123', ruleSourceName: 'grafana' }); + }); + + it('should throw for malformed identifier', () => { + expect(() => parse('foo$bar$baz', false)).toThrow(/failed to parse/i); + }); }); diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index af8a005041b..f7292ff59b6 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -91,10 +91,25 @@ function escapeDollars(value: string): string { return value.replace(/\$/g, '_DOLLAR_'); } -function unesacapeDollars(value: string): string { +function unescapeDollars(value: string): string { return value.replace(/\_DOLLAR\_/g, '$'); } +/** + * deal with Unix-style path separators "/" (replaced with \x1f – unit separator) + * and Windows-style path separators "\" (replaced with \x1e – record separator) + * we need this to side-step proxies that automatically decode %2F to prevent path traversal attacks + * we'll use some non-printable characters from the ASCII table that will get encoded properly but very unlikely + * to ever be used in a rule name or namespace + */ +function escapePathSeparators(value: string): string { + return value.replace(/\//g, '\x1f').replace(/\\/g, '\x1e'); +} + +function unescapePathSeparators(value: string): string { + return value.replace(/\x1f/g, '/').replace(/\x1e/g, '\\'); +} + export function parse(value: string, decodeFromUri = false): RuleIdentifier { const source = decodeFromUri ? decodeURIComponent(value) : value; const parts = source.split('$'); @@ -104,7 +119,7 @@ export function parse(value: string, decodeFromUri = false): RuleIdentifier { } if (parts.length === 5) { - const [prefix, ruleSourceName, namespace, groupName, hash] = parts.map(unesacapeDollars); + const [prefix, ruleSourceName, namespace, groupName, hash] = parts.map(unescapeDollars).map(unescapePathSeparators); if (prefix === cloudRuleIdentifierPrefix) { return { ruleSourceName, namespace, groupName, rulerRuleHash: hash }; @@ -145,6 +160,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string { ] .map(String) .map(escapeDollars) + .map(escapePathSeparators) .join('$'); } @@ -157,6 +173,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string { ] .map(String) .map(escapeDollars) + .map(escapePathSeparators) .join('$'); } From 9ff2765bb9e7938c88f2333647a6210589a06535 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Fri, 4 Nov 2022 11:14:59 +0000 Subject: [PATCH 037/926] Auth: Check for OrgUsersAdd on frontend to display pending invites (#58217) --- public/app/features/invites/state/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/invites/state/actions.ts b/public/app/features/invites/state/actions.ts index 0e7c891da25..d4af5d085b1 100644 --- a/public/app/features/invites/state/actions.ts +++ b/public/app/features/invites/state/actions.ts @@ -4,7 +4,7 @@ import { FormModel } from 'app/features/org/UserInviteForm'; import { AccessControlAction, createAsyncThunk, Invitee } from 'app/types'; export const fetchInvitees = createAsyncThunk('users/fetchInvitees', async () => { - if (!contextSrv.hasPermission(AccessControlAction.UsersCreate)) { + if (!contextSrv.hasPermission(AccessControlAction.OrgUsersAdd)) { return []; } From 9c1c10ab9a522db4a99310e1f2e01148b86e222d Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Fri, 4 Nov 2022 13:21:18 +0200 Subject: [PATCH 038/926] Remove base and arch args (#58209) --- .drone.yml | 22 +++++++++------------- scripts/drone/steps/lib.star | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.drone.yml b/.drone.yml index 51b025307c9..b8a2ee5796c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1447,8 +1447,7 @@ steps: - name: docker path: /var/run/docker.sock - commands: - - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana --base - alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana depends_on: - build-docker-images - build-docker-images-ubuntu @@ -1468,8 +1467,7 @@ steps: repo: - grafana/grafana - commands: - - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-oss --base - alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-oss depends_on: - build-docker-images - build-docker-images-ubuntu @@ -3319,8 +3317,8 @@ steps: - name: docker path: /var/run/docker.sock - commands: - - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana --base - alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag ${TAG} + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana --version-tag + ${TAG} depends_on: - fetch-images-oss environment: @@ -3336,8 +3334,8 @@ steps: - name: docker path: /var/run/docker.sock - commands: - - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-oss --base - alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag ${TAG} + - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-oss --version-tag + ${TAG} depends_on: - fetch-images-oss environment: @@ -3408,8 +3406,7 @@ steps: path: /var/run/docker.sock - commands: - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-enterprise - --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag - ${TAG} + --version-tag ${TAG} depends_on: - fetch-images-enterprise environment: @@ -3480,8 +3477,7 @@ steps: path: /var/run/docker.sock - commands: - ./bin/grabpl artifacts docker publish --security --dockerhub-repo grafana/grafana-enterprise - --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7 --version-tag - ${TAG} + --version-tag ${TAG} depends_on: - fetch-images-enterprise environment: @@ -5450,6 +5446,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 284ccba3c82e516df5db917eec0afde057a622394002b3a311276fe528b7a86c +hmac: c05242e46e10d9e2af78c038a72879351ab553787b2732c8afaf32c706e45096 ... diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 3011073c4e5..b15ca337fba 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -815,7 +815,7 @@ def publish_images_step(edition, ver_mode, mode, docker_repo, trigger=None): else: mode = '' - cmd = './bin/grabpl artifacts docker publish {}--dockerhub-repo {} --base alpine --base ubuntu --arch amd64 --arch arm64 --arch armv7'.format( + cmd = './bin/grabpl artifacts docker publish {}--dockerhub-repo {}'.format( mode, docker_repo) if ver_mode == 'release': From b7efd46e2d514a7a3ea4527467bfb2be7f16a17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Fri, 4 Nov 2022 12:21:46 +0100 Subject: [PATCH 039/926] Internationalization: Translate TimeRangeList component (#58131) --- .../DateTimePickers/TimeRangePicker/TimeRangeList.tsx | 5 +++-- public/locales/de-DE/grafana.json | 2 ++ public/locales/en-US/grafana.json | 2 ++ public/locales/es-ES/grafana.json | 2 ++ public/locales/fr-FR/grafana.json | 4 +++- public/locales/pseudo-LOCALE/grafana.json | 2 ++ public/locales/zh-Hans/grafana.json | 2 ++ 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx index 45b0dfdcbaf..3995086da65 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx @@ -4,6 +4,7 @@ import React, { ReactNode } from 'react'; import { TimeOption } from '@grafana/data'; import { stylesFactory } from '../../../themes'; +import { t } from '../../../utils/i18n'; import { TimePickerTitle } from './TimePickerTitle'; import { TimeRangeOption } from './TimeRangeOption'; @@ -65,14 +66,14 @@ const Options = ({ options, value, onChange, title }: Props) => { return ( <> -
    +
      {options.map((option, index) => ( ))}
    diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index a2fb0ea6140..7904fc13f48 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -416,6 +416,8 @@ "select-time": "" }, "time-range": { + "aria-role": "", + "default-title": "", "example-title": "", "specify": "" } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 903d64bad55..bf33496a5f3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -416,6 +416,8 @@ "select-time": "Select a time range" }, "time-range": { + "aria-role": "Time range selection", + "default-title": "Time ranges", "example-title": "Example time ranges", "specify": "Specify time range <1>" } diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 01eebf5617c..20a1b8f6426 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -416,6 +416,8 @@ "select-time": "" }, "time-range": { + "aria-role": "", + "default-title": "", "example-title": "", "specify": "" } diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 955a6238492..3768f52b6c8 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -416,6 +416,8 @@ "select-time": "" }, "time-range": { + "aria-role": "", + "default-title": "", "example-title": "", "specify": "" } @@ -433,7 +435,7 @@ "email-label": "Adresse e-mail", "name-error": "Un nom est obligatoire", "name-label": "Nom", - "username-label": "Nom d’utilisateur" + "username-label": "Nom d´utilisateur" }, "title": "Modifier le profil" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index e6ad48ed38a..8432633bbbc 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -416,6 +416,8 @@ "select-time": "Ŝęľęčŧ ä ŧįmę řäʼnģę" }, "time-range": { + "aria-role": "Ŧįmę řäʼnģę şęľęčŧįőʼn", + "default-title": "Ŧįmę řäʼnģęş", "example-title": "Ēχämpľę ŧįmę řäʼnģęş", "specify": "Ŝpęčįƒy ŧįmę řäʼnģę <1>" } diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 85cdd237ae6..1df34e9f761 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -416,6 +416,8 @@ "select-time": "" }, "time-range": { + "aria-role": "", + "default-title": "", "example-title": "", "specify": "" } From 72d0c6b428846fe7b86a2c529fb37c45bc3b8e83 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Fri, 4 Nov 2022 12:39:54 +0000 Subject: [PATCH 040/926] Auth: add IsServiceAccount to IsRealUser (#58015) * add: IsServiceAccount to SignedInUser and IsRealUser * fix: linting error * refactor: add function IsServiceAccountUser() By adding the function IsServiceAccountUser() we use it to identify for ServiceAccounts in the HasUniqueID() since caching is built up on having a uniqueID, see comment: https://github.com/grafana/grafana/pull/58015#discussion_r1011361880 --- pkg/services/contexthandler/contexthandler.go | 3 ++- pkg/services/ngalert/schedule/schedule.go | 1 + pkg/services/querylibrary/tests/common.go | 11 ++++++----- .../serviceaccounts/serviceaccounts.go | 18 +++++++++++++++++- pkg/services/user/model.go | 15 +++++++++++++-- pkg/services/user/userimpl/store.go | 3 ++- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index bc8836bad31..a19c8c2d7fb 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -312,7 +312,8 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo } if apikey.ServiceAccountId == nil || *apikey.ServiceAccountId < 1 { //There is no service account attached to the apikey - //Use the old APIkey method. This provides backwards compatibility. + // Use the old APIkey method. This provides backwards compatibility. + // will probably have to be supported for a long time. reqContext.SignedInUser = &user.SignedInUser{} reqContext.OrgRole = apikey.Role reqContext.ApiKeyID = apikey.Id diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 23b01d13b6c..a072b2dae2d 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -328,6 +328,7 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR start := sch.clock.Now() schedulerUser := &user.SignedInUser{ + // FIXME: add is service account and refactor to a service account instead of a user UserID: -1, Login: "grafana_scheduler", OrgID: e.rule.OrgID, diff --git a/pkg/services/querylibrary/tests/common.go b/pkg/services/querylibrary/tests/common.go index c3aaf3734d9..f8cfe67ccfa 100644 --- a/pkg/services/querylibrary/tests/common.go +++ b/pkg/services/querylibrary/tests/common.go @@ -38,11 +38,12 @@ func createServiceAccountAdminToken(t *testing.T, name string, env *server.TestE }) return keyGen.ClientSecret, &user.SignedInUser{ - UserID: account.ID, - Email: account.Email, - Name: account.Name, - Login: account.Login, - OrgID: account.OrgID, + UserID: account.ID, + Email: account.Email, + Name: account.Name, + Login: account.Login, + OrgID: account.OrgID, + IsServiceAccount: true, } } diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 63e3cd336a0..940a9cb3ce7 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -7,13 +7,29 @@ import ( "github.com/grafana/grafana/pkg/services/user" ) -// this should reflect the api +/* +ServiceAccountService is the service that manages service accounts. + +Service accounts are used to authenticate API requests. They are not users and +do not have a password. +*/ type Service interface { CreateServiceAccount(ctx context.Context, orgID int64, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) } +/* +Store is the database store for service accounts. + +migration from apikeys to service accounts: +HideApiKeyTab is used to hide the api key tab in the UI. +MigrateApiKeysToServiceAccounts migrates all API keys to service accounts. +MigrateApiKey migrates a single API key to a service account. + +// only used for interal api calls +RevertApiKey reverts a single service account to an API key. +*/ type Store interface { CreateServiceAccount(ctx context.Context, orgID int64, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) SearchOrgServiceAccounts(ctx context.Context, orgID int64, query string, filter ServiceAccountFilter, page int, limit int, diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 4172ae78918..4dd8f9dc8a3 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -203,6 +203,7 @@ type SignedInUser struct { Name string Email string ApiKeyID int64 `xorm:"api_key_id"` + IsServiceAccount bool `xorm:"is_service_account"` OrgCount int IsGrafanaAdmin bool IsAnonymous bool @@ -276,16 +277,26 @@ func (u *SignedInUser) HasRole(role roletype.RoleType) bool { return u.OrgRole.Includes(role) } +// IsRealUser returns true if the user is a real user and not a service account func (u *SignedInUser) IsRealUser() bool { - return u.UserID > 0 + // backwards compatibility + // checking if userId the user is a real user + // previously we used to check if the UserId was 0 or -1 + // and not a service account + return u.UserID > 0 && !u.IsServiceAccountUser() } func (u *SignedInUser) IsApiKeyUser() bool { return u.ApiKeyID > 0 } +// IsServiceAccountUser returns true if the user is a service account +func (u *SignedInUser) IsServiceAccountUser() bool { + return u.IsServiceAccount +} + func (u *SignedInUser) HasUniqueId() bool { - return u.IsRealUser() || u.IsApiKeyUser() + return u.IsRealUser() || u.IsApiKeyUser() || u.IsServiceAccountUser() } func (u *SignedInUser) GetCacheKey() (string, error) { diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index a27850bfb4d..c368be1389c 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -344,7 +344,8 @@ func (ss *sqlStore) GetSignedInUser(ctx context.Context, query *user.GetSignedIn user_auth.auth_id as external_auth_id, org.name as org_name, org_user.role as org_role, - org.id as org_id + org.id as org_id, + u.is_service_account as is_service_account FROM ` + ss.dialect.Quote("user") + ` as u LEFT OUTER JOIN user_auth on user_auth.user_id = u.id LEFT OUTER JOIN org_user on org_user.org_id = ` + orgId + ` and org_user.user_id = u.id From ff5cc3e640ef3c4d3bb757f365088683db0eea92 Mon Sep 17 00:00:00 2001 From: Yuriy Tseretyan Date: Fri, 4 Nov 2022 09:28:38 -0400 Subject: [PATCH 041/926] Chore: Update cloud monitoring and Azure data sources to support contextual logs (#57844) * update cloud monitoring to use log from context * update azure monitor to use contextual logger --- pkg/tsdb/azuremonitor/azlog/azlog.go | 23 -------- .../azuremonitor-resource-handler.go | 17 +++--- pkg/tsdb/azuremonitor/azuremonitor.go | 8 ++- pkg/tsdb/azuremonitor/azuremonitor_test.go | 4 +- .../azure-log-analytics-datasource.go | 39 +++++++------- .../azure-log-analytics-datasource_test.go | 13 +++-- pkg/tsdb/azuremonitor/macros/macros.go | 16 +++--- pkg/tsdb/azuremonitor/macros/macros_test.go | 6 ++- .../metrics/azuremonitor-datasource.go | 36 ++++++------- .../metrics/azuremonitor-datasource_test.go | 15 +++--- .../azure-resource-graph-datasource.go | 36 ++++++------- .../azure-resource-graph-datasource_test.go | 19 ++++--- pkg/tsdb/cloudmonitoring/annotation_query.go | 8 +-- pkg/tsdb/cloudmonitoring/cloudmonitoring.go | 26 +++++----- .../cloudmonitoring/cloudmonitoring_test.go | 52 +++++++++---------- .../cloudmonitoring/time_series_filter.go | 6 +-- pkg/tsdb/cloudmonitoring/time_series_query.go | 25 ++++----- pkg/tsdb/cloudmonitoring/types.go | 4 ++ 18 files changed, 176 insertions(+), 177 deletions(-) delete mode 100644 pkg/tsdb/azuremonitor/azlog/azlog.go diff --git a/pkg/tsdb/azuremonitor/azlog/azlog.go b/pkg/tsdb/azuremonitor/azlog/azlog.go deleted file mode 100644 index bb946053175..00000000000 --- a/pkg/tsdb/azuremonitor/azlog/azlog.go +++ /dev/null @@ -1,23 +0,0 @@ -package azlog - -import "github.com/grafana/grafana/pkg/infra/log" - -var ( - azlog = log.New("tsdb.azuremonitor") -) - -func Warn(msg string, args ...interface{}) { - azlog.Warn(msg, args) -} - -func Debug(msg string, args ...interface{}) { - azlog.Debug(msg, args) -} - -func Error(msg string, args ...interface{}) { - azlog.Error(msg, args) -} - -func Info(msg string, args ...interface{}) { - azlog.Info(msg, args) -} diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go index 264891e935c..674928c9391 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -22,7 +22,8 @@ func getTarget(original string) (target string, err error) { return } -type httpServiceProxy struct{} +type httpServiceProxy struct { +} func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *http.Client) http.ResponseWriter { res, err := cli.Do(req) @@ -30,13 +31,13 @@ func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *ht rw.WriteHeader(http.StatusInternalServerError) _, err = rw.Write([]byte(fmt.Sprintf("unexpected error %v", err))) if err != nil { - azlog.Error("Unable to write HTTP response", "error", err) + logger.Error("Unable to write HTTP response", "error", err) } return nil } defer func() { if err := res.Body.Close(); err != nil { - azlog.Warn("Failed to close response body", "err", err) + logger.Warn("Failed to close response body", "err", err) } }() @@ -45,14 +46,14 @@ func (s *httpServiceProxy) Do(rw http.ResponseWriter, req *http.Request, cli *ht rw.WriteHeader(http.StatusInternalServerError) _, err = rw.Write([]byte(fmt.Sprintf("unexpected error %v", err))) if err != nil { - azlog.Error("Unable to write HTTP response", "error", err) + logger.Error("Unable to write HTTP response", "error", err) } return nil } rw.WriteHeader(res.StatusCode) _, err = rw.Write(body) if err != nil { - azlog.Error("Unable to write HTTP response", "error", err) + logger.Error("Unable to write HTTP response", "error", err) } for k, v := range res.Header { @@ -83,13 +84,13 @@ func writeResponse(rw http.ResponseWriter, code int, msg string) { rw.WriteHeader(http.StatusBadRequest) _, err := rw.Write([]byte(msg)) if err != nil { - azlog.Error("Unable to write HTTP response", "error", err) + logger.Error("Unable to write HTTP response", "error", err) } } func (s *Service) handleResourceReq(subDataSource string) func(rw http.ResponseWriter, req *http.Request) { return func(rw http.ResponseWriter, req *http.Request) { - azlog.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) + logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) newPath, err := getTarget(req.URL.Path) if err != nil { diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index 35c90ab33da..826b63e4246 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -16,7 +16,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" @@ -25,6 +27,8 @@ import ( "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) +var logger = log.New("tsdb.azuremonitor") + func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer tracing.Tracer) *Service { proxy := &httpServiceProxy{} executors := map[string]azDatasourceExecutor{ @@ -159,7 +163,7 @@ func getAzureRoutes(cloud string, jsonData json.RawMessage) (map[string]types.Az } type azDatasourceExecutor interface { - ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) + ExecuteTimeSeriesQuery(ctx context.Context, logger log.Logger, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) } @@ -191,7 +195,7 @@ func (s *Service) newQueryMux() *datasource.QueryTypeMux { if !ok { return nil, fmt.Errorf("missing service for %s", dst) } - return executor.ExecuteTimeSeriesQuery(ctx, req.Queries, dsInfo, service.HTTPClient, service.URL, s.tracer) + return executor.ExecuteTimeSeriesQuery(ctx, logger, req.Queries, dsInfo, service.HTTPClient, service.URL, s.tracer) }) } return mux diff --git a/pkg/tsdb/azuremonitor/azuremonitor_test.go b/pkg/tsdb/azuremonitor/azuremonitor_test.go index 1cc87c46717..e2d428b9ddd 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" @@ -134,8 +135,7 @@ type fakeExecutor struct { func (f *fakeExecutor) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) { } -func (f *fakeExecutor) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, - url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { +func (f *fakeExecutor) ExecuteTimeSeriesQuery(ctx context.Context, logger log.Logger, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { if client == nil { f.t.Errorf("The HTTP client for %s is missing", f.queryType) } else { diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index d2b0b28ca9a..cda34cea065 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -18,8 +18,8 @@ import ( "go.opentelemetry.io/otel/attribute" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -49,17 +49,16 @@ func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, re // 1. build the AzureMonitor url and querystring for each query // 2. executes each query by calling the Azure Monitor API // 3. parses the responses for each query into data frames -func (e *AzureLogAnalyticsDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, - url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { +func (e *AzureLogAnalyticsDatasource) ExecuteTimeSeriesQuery(ctx context.Context, logger log.Logger, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() - - queries, err := e.buildQueries(originalQueries, dsInfo) + ctxLogger := logger.FromContext(ctx) + queries, err := e.buildQueries(ctxLogger, originalQueries, dsInfo) if err != nil { return nil, err } for _, query := range queries { - result.Responses[query.RefID] = e.executeQuery(ctx, query, dsInfo, client, url, tracer) + result.Responses[query.RefID] = e.executeQuery(ctx, ctxLogger, query, dsInfo, client, url, tracer) } return result, nil @@ -88,7 +87,7 @@ func getApiURL(queryJSONModel types.LogJSONQuery) string { } } -func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureLogAnalyticsQuery, error) { +func (e *AzureLogAnalyticsDatasource) buildQueries(logger log.Logger, queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureLogAnalyticsQuery, error) { azureLogAnalyticsQueries := []*AzureLogAnalyticsQuery{} for _, query := range queries { @@ -99,7 +98,7 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, } azureLogAnalyticsTarget := queryJSONModel.AzureLogAnalytics - azlog.Debug("AzureLogAnalytics", "target", azureLogAnalyticsTarget) + logger.Debug("AzureLogAnalytics", "target", azureLogAnalyticsTarget) resultFormat := azureLogAnalyticsTarget.ResultFormat if resultFormat == "" { @@ -109,7 +108,7 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, apiURL := getApiURL(queryJSONModel) params := url.Values{} - rawQuery, err := macros.KqlInterpolate(query, dsInfo, azureLogAnalyticsTarget.Query, "TimeGenerated") + rawQuery, err := macros.KqlInterpolate(logger, query, dsInfo, azureLogAnalyticsTarget.Query, "TimeGenerated") if err != nil { return nil, err } @@ -129,7 +128,7 @@ func (e *AzureLogAnalyticsDatasource) buildQueries(queries []backend.DataQuery, return azureLogAnalyticsQueries, nil } -func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *AzureLogAnalyticsQuery, dsInfo types.DatasourceInfo, client *http.Client, +func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, logger log.Logger, query *AzureLogAnalyticsQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} @@ -151,7 +150,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A return dataResponseErrorWithExecuted(fmt.Errorf("credentials for Log Analytics are no longer supported. Go to the data source configuration to update Azure Monitor credentials")) } - req, err := e.createRequest(ctx, dsInfo, url) + req, err := e.createRequest(ctx, logger, url) if err != nil { dataResponse.Error = err return dataResponse @@ -171,13 +170,13 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A tracer.Inject(ctx, req.Header, span) - azlog.Debug("AzureLogAnalytics", "Request ApiURL", req.URL.String()) + logger.Debug("AzureLogAnalytics", "Request ApiURL", req.URL.String()) res, err := client.Do(req) if err != nil { return dataResponseErrorWithExecuted(err) } - logResponse, err := e.unmarshalResponse(res) + logResponse, err := e.unmarshalResponse(logger, res) if err != nil { return dataResponseErrorWithExecuted(err) } @@ -204,7 +203,7 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A model.Get("azureLogAnalytics").Get("workspace").MustString()) if err != nil { frame.AppendNotices(data.Notice{Severity: data.NoticeSeverityWarning, Text: "could not add custom metadata: " + err.Error()}) - azlog.Warn("failed to add custom metadata to azure log analytics response", err) + logger.Warn("failed to add custom metadata to azure log analytics response", err) } if query.ResultFormat == types.TimeSeries { @@ -229,10 +228,10 @@ func appendErrorNotice(frame *data.Frame, err *AzureLogAnalyticsAPIError) { } } -func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { +func (e *AzureLogAnalyticsDatasource) createRequest(ctx context.Context, logger log.Logger, url string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - azlog.Debug("Failed to create request", "error", err) + logger.Debug("Failed to create request", "error", err) return nil, fmt.Errorf("%v: %w", "failed to create request", err) } req.URL.Path = "/" @@ -279,19 +278,19 @@ func (ar *AzureLogAnalyticsResponse) GetPrimaryResultTable() (*types.AzureRespon return nil, fmt.Errorf("no data as PrimaryResult table is missing from the response") } -func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (AzureLogAnalyticsResponse, error) { +func (e *AzureLogAnalyticsDatasource) unmarshalResponse(logger log.Logger, res *http.Response) (AzureLogAnalyticsResponse, error) { body, err := io.ReadAll(res.Body) if err != nil { return AzureLogAnalyticsResponse{}, err } defer func() { if err := res.Body.Close(); err != nil { - azlog.Warn("Failed to close response body", "err", err) + logger.Warn("Failed to close response body", "err", err) } }() if res.StatusCode/100 != 2 { - azlog.Debug("Request failed", "status", res.Status, "body", string(body)) + logger.Debug("Request failed", "status", res.Status, "body", string(body)) return AzureLogAnalyticsResponse{}, fmt.Errorf("request failed, status: %s, body: %s", res.Status, string(body)) } @@ -300,7 +299,7 @@ func (e *AzureLogAnalyticsDatasource) unmarshalResponse(res *http.Response) (Azu d.UseNumber() err = d.Decode(&data) if err != nil { - azlog.Debug("Failed to unmarshal Azure Log Analytics response", "error", err, "status", res.Status, "body", string(body)) + logger.Debug("Failed to unmarshal Azure Log Analytics response", "error", err, "status", res.Status, "body", string(body)) return AzureLogAnalyticsResponse{}, err } diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go index 74c80edf719..008d85f329a 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource_test.go @@ -11,11 +11,15 @@ import ( "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" - "github.com/stretchr/testify/require" ) +var logger = log.New("test") + func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { datasource := &AzureLogAnalyticsDatasource{} fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) @@ -172,7 +176,7 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - queries, err := datasource.buildQueries(tt.queryModel, types.DatasourceInfo{}) + queries, err := datasource.buildQueries(logger, tt.queryModel, types.DatasourceInfo{}) tt.Err(t, err) if diff := cmp.Diff(tt.azureLogAnalyticsQueries[0], queries[0]); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) @@ -184,7 +188,6 @@ func TestBuildingAzureLogAnalyticsQueries(t *testing.T) { func TestLogAnalyticsCreateRequest(t *testing.T) { ctx := context.Background() url := "http://ds" - dsInfo := types.DatasourceInfo{} tests := []struct { name string @@ -203,7 +206,7 @@ func TestLogAnalyticsCreateRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ds := AzureLogAnalyticsDatasource{} - req, err := ds.createRequest(ctx, dsInfo, url) + req, err := ds.createRequest(ctx, logger, url) tt.Err(t, err) if req.URL.String() != tt.expectedURL { t.Errorf("Expecting %s, got %s", tt.expectedURL, req.URL.String()) @@ -231,7 +234,7 @@ func Test_executeQueryErrorWithDifferentLogAnalyticsCreds(t *testing.T) { TimeRange: backend.TimeRange{}, } tracer := tracing.InitializeTracerForTest() - res := ds.executeQuery(ctx, query, dsInfo, &http.Client{}, dsInfo.Services["Azure Log Analytics"].URL, tracer) + res := ds.executeQuery(ctx, logger, query, dsInfo, &http.Client{}, dsInfo.Services["Azure Log Analytics"].URL, tracer) if res.Error == nil { t.Fatal("expecting an error") } diff --git a/pkg/tsdb/azuremonitor/macros/macros.go b/pkg/tsdb/azuremonitor/macros/macros.go index 9eba812a543..922ee546c23 100644 --- a/pkg/tsdb/azuremonitor/macros/macros.go +++ b/pkg/tsdb/azuremonitor/macros/macros.go @@ -9,8 +9,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/grafana/grafana/pkg/tsdb/legacydata/interval" ) @@ -34,17 +34,17 @@ type kqlMacroEngine struct { // - $__escapeMulti('\\vm\eth0\Total','\\vm\eth2\Total') -> @'\\vm\eth0\Total',@'\\vm\eth2\Total' // KqlInterpolate interpolates macros for Kusto Query Language (KQL) queries -func KqlInterpolate(query backend.DataQuery, dsInfo types.DatasourceInfo, kql string, defaultTimeField ...string) (string, error) { +func KqlInterpolate(logger log.Logger, query backend.DataQuery, dsInfo types.DatasourceInfo, kql string, defaultTimeField ...string) (string, error) { engine := kqlMacroEngine{} defaultTimeFieldForAllDatasources := "timestamp" if len(defaultTimeField) > 0 { defaultTimeFieldForAllDatasources = defaultTimeField[0] } - return engine.Interpolate(query, dsInfo, kql, defaultTimeFieldForAllDatasources) + return engine.Interpolate(logger, query, dsInfo, kql, defaultTimeFieldForAllDatasources) } -func (m *kqlMacroEngine) Interpolate(query backend.DataQuery, dsInfo types.DatasourceInfo, kql string, defaultTimeField string) (string, error) { +func (m *kqlMacroEngine) Interpolate(logger log.Logger, query backend.DataQuery, dsInfo types.DatasourceInfo, kql string, defaultTimeField string) (string, error) { m.timeRange = query.TimeRange m.query = query rExp, _ := regexp.Compile(sExpr) @@ -74,7 +74,7 @@ func (m *kqlMacroEngine) Interpolate(query backend.DataQuery, dsInfo types.Datas for i, arg := range args { args[i] = strings.Trim(arg, " ") } - res, err := m.evaluateMacro(groups[1], defaultTimeField, args, dsInfo) + res, err := m.evaluateMacro(logger, groups[1], defaultTimeField, args, dsInfo) if err != nil && macroError == nil { macroError = err return "macro_error()" @@ -89,7 +89,7 @@ func (m *kqlMacroEngine) Interpolate(query backend.DataQuery, dsInfo types.Datas return kql, nil } -func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, args []string, dsInfo types.DatasourceInfo) (string, error) { +func (m *kqlMacroEngine) evaluateMacro(logger log.Logger, name string, defaultTimeField string, args []string, dsInfo types.DatasourceInfo) (string, error) { switch name { case "timeFilter": timeColumn := defaultTimeField @@ -112,14 +112,14 @@ func (m *kqlMacroEngine) evaluateMacro(name string, defaultTimeField string, arg defaultInterval := time.Duration((to - from) / 60) model, err := simplejson.NewJson(m.query.JSON) if err != nil { - azlog.Warn("Unable to parse model from query", "JSON", m.query.JSON) + logger.Warn("Unable to parse model from query", "JSON", m.query.JSON) it = defaultInterval } else { it, err = interval.GetIntervalFrom(&datasources.DataSource{ JsonData: simplejson.NewFromAny(dsInfo.JSONData), }, model, defaultInterval) if err != nil { - azlog.Warn("Unable to get interval from query", "model", model) + logger.Warn("Unable to get interval from query", "model", model) it = defaultInterval } } diff --git a/pkg/tsdb/azuremonitor/macros/macros_test.go b/pkg/tsdb/azuremonitor/macros/macros_test.go index 7fa92c15067..5ae58438ca0 100644 --- a/pkg/tsdb/azuremonitor/macros/macros_test.go +++ b/pkg/tsdb/azuremonitor/macros/macros_test.go @@ -7,8 +7,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) func TestAzureLogAnalyticsMacros(t *testing.T) { @@ -126,7 +128,7 @@ func TestAzureLogAnalyticsMacros(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defaultTimeField := "TimeGenerated" - rawQuery, err := KqlInterpolate(tt.query, types.DatasourceInfo{}, tt.kql, defaultTimeField) + rawQuery, err := KqlInterpolate(log.New("test"), tt.query, types.DatasourceInfo{}, tt.kql, defaultTimeField) tt.Err(t, err) if diff := cmp.Diff(tt.expected, rawQuery, cmpopts.EquateNaNs()); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go index 4404cb91b86..9b1b670d423 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource.go @@ -17,9 +17,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/resourcegraph" azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" @@ -46,23 +46,23 @@ func (e *AzureMonitorDatasource) ResourceRequest(rw http.ResponseWriter, req *ht // 1. build the AzureMonitor url and querystring for each query // 2. executes each query by calling the Azure Monitor API // 3. parses the responses for each query into data frames -func (e *AzureMonitorDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, - url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { +func (e *AzureMonitorDatasource) ExecuteTimeSeriesQuery(ctx context.Context, logger log.Logger, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := backend.NewQueryDataResponse() + ctxLogger := logger.FromContext(ctx) - queries, err := e.buildQueries(originalQueries, dsInfo) + queries, err := e.buildQueries(ctxLogger, originalQueries, dsInfo) if err != nil { return nil, err } for _, query := range queries { - result.Responses[query.RefID] = e.executeQuery(ctx, query, dsInfo, client, url, tracer) + result.Responses[query.RefID] = e.executeQuery(ctx, ctxLogger, query, dsInfo, client, url, tracer) } return result, nil } -func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*types.AzureMonitorQuery, error) { +func (e *AzureMonitorDatasource) buildQueries(logger log.Logger, queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*types.AzureMonitorQuery, error) { azureMonitorQueries := []*types.AzureMonitorQuery{} for _, query := range queries { @@ -171,7 +171,7 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf target = params.Encode() if setting.Env == setting.Dev { - azlog.Debug("Azuremonitor request", "params", params) + logger.Debug("Azuremonitor request", "params", params) } azureMonitorQueries = append(azureMonitorQueries, &types.AzureMonitorQuery{ @@ -188,11 +188,11 @@ func (e *AzureMonitorDatasource) buildQueries(queries []backend.DataQuery, dsInf return azureMonitorQueries, nil } -func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types.AzureMonitorQuery, dsInfo types.DatasourceInfo, cli *http.Client, +func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, logger log.Logger, query *types.AzureMonitorQuery, dsInfo types.DatasourceInfo, cli *http.Client, url string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} - req, err := e.createRequest(ctx, dsInfo, url) + req, err := e.createRequest(ctx, logger, url) if err != nil { dataResponse.Error = err return dataResponse @@ -215,8 +215,8 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. defer span.End() tracer.Inject(ctx, req.Header, span) - azlog.Debug("AzureMonitor", "Request ApiURL", req.URL.String()) - azlog.Debug("AzureMonitor", "Target", query.Target) + logger.Debug("AzureMonitor", "Request ApiURL", req.URL.String()) + logger.Debug("AzureMonitor", "Target", query.Target) res, err := cli.Do(req) if err != nil { dataResponse.Error = err @@ -224,11 +224,11 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. } defer func() { if err := res.Body.Close(); err != nil { - azlog.Warn("Failed to close response body", "err", err) + logger.Warn("Failed to close response body", "err", err) } }() - data, err := e.unmarshalResponse(res) + data, err := e.unmarshalResponse(logger, res) if err != nil { dataResponse.Error = err return dataResponse @@ -249,10 +249,10 @@ func (e *AzureMonitorDatasource) executeQuery(ctx context.Context, query *types. return dataResponse } -func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, url string) (*http.Request, error) { +func (e *AzureMonitorDatasource) createRequest(ctx context.Context, logger log.Logger, url string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - azlog.Debug("Failed to create request", "error", err) + logger.Debug("Failed to create request", "error", err) return nil, fmt.Errorf("%v: %w", "Failed to create request", err) } req.Header.Set("Content-Type", "application/json") @@ -260,21 +260,21 @@ func (e *AzureMonitorDatasource) createRequest(ctx context.Context, dsInfo types return req, nil } -func (e *AzureMonitorDatasource) unmarshalResponse(res *http.Response) (types.AzureMonitorResponse, error) { +func (e *AzureMonitorDatasource) unmarshalResponse(logger log.Logger, res *http.Response) (types.AzureMonitorResponse, error) { body, err := io.ReadAll(res.Body) if err != nil { return types.AzureMonitorResponse{}, err } if res.StatusCode/100 != 2 { - azlog.Debug("Request failed", "status", res.Status, "body", string(body)) + logger.Debug("Request failed", "status", res.Status, "body", string(body)) return types.AzureMonitorResponse{}, fmt.Errorf("request failed, status: %s", res.Status) } var data types.AzureMonitorResponse err = json.Unmarshal(body, &data) if err != nil { - azlog.Debug("Failed to unmarshal AzureMonitor response", "error", err, "status", res.Status, "body", string(body)) + logger.Debug("Failed to unmarshal AzureMonitor response", "error", err, "status", res.Status, "body", string(body)) return types.AzureMonitorResponse{}, err } diff --git a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go index ffb962c6087..722421cf3d7 100644 --- a/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/metrics/azuremonitor-datasource_test.go @@ -15,11 +15,13 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/components/simplejson" - azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + azTime "github.com/grafana/grafana/pkg/tsdb/azuremonitor/time" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) func TestAzureMonitorBuildQueries(t *testing.T) { @@ -279,7 +281,7 @@ func TestAzureMonitorBuildQueries(t *testing.T) { azureMonitorQuery.URL = "/subscriptions/12345678-aaaa-bbbb-cccc-123456789abc/resourceGroups/grafanastaging/providers/Microsoft.Compute/virtualMachines/grafana/providers/microsoft.insights/metrics" } - queries, err := datasource.buildQueries(tsdbQuery, dsInfo) + queries, err := datasource.buildQueries(log.New("test"), tsdbQuery, dsInfo) require.NoError(t, err) if diff := cmp.Diff(azureMonitorQuery, queries[0], cmpopts.IgnoreUnexported(simplejson.Json{}), cmpopts.IgnoreFields(types.AzureMonitorQuery{}, "Params")); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) @@ -310,7 +312,7 @@ func TestCustomNamespace(t *testing.T) { }, } - result, err := datasource.buildQueries(q, types.DatasourceInfo{}) + result, err := datasource.buildQueries(log.New("test"), q, types.DatasourceInfo{}) require.NoError(t, err) expected := "custom/namespace" require.Equal(t, expected, result[0].Params.Get("metricnamespace")) @@ -737,7 +739,6 @@ func loadTestFile(t *testing.T, name string) types.AzureMonitorResponse { func TestAzureMonitorCreateRequest(t *testing.T) { ctx := context.Background() - dsInfo := types.DatasourceInfo{} url := "http://ds/" tests := []struct { @@ -759,7 +760,7 @@ func TestAzureMonitorCreateRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ds := AzureMonitorDatasource{} - req, err := ds.createRequest(ctx, dsInfo, url) + req, err := ds.createRequest(ctx, log.New("test"), url) tt.Err(t, err) if req.URL.String() != tt.expectedURL { t.Errorf("Expecting %s, got %s", tt.expectedURL, req.URL.String()) diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index 48554815d80..d852c95407e 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -17,9 +17,9 @@ import ( "go.opentelemetry.io/otel/attribute" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azlog" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/loganalytics" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" @@ -57,19 +57,19 @@ func (e *AzureResourceGraphDatasource) ResourceRequest(rw http.ResponseWriter, r // 1. builds the AzureMonitor url and querystring for each query // 2. executes each query by calling the Azure Monitor API // 3. parses the responses for each query into data frames -func (e *AzureResourceGraphDatasource) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, - url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { +func (e *AzureResourceGraphDatasource) ExecuteTimeSeriesQuery(ctx context.Context, logger log.Logger, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, tracer tracing.Tracer) (*backend.QueryDataResponse, error) { result := &backend.QueryDataResponse{ Responses: map[string]backend.DataResponse{}, } + ctxLogger := logger.FromContext(ctx) - queries, err := e.buildQueries(originalQueries, dsInfo) + queries, err := e.buildQueries(ctxLogger, originalQueries, dsInfo) if err != nil { return nil, err } for _, query := range queries { - result.Responses[query.RefID] = e.executeQuery(ctx, query, dsInfo, client, url, tracer) + result.Responses[query.RefID] = e.executeQuery(ctx, ctxLogger, query, dsInfo, client, url, tracer) } return result, nil @@ -82,7 +82,7 @@ type argJSONQuery struct { } `json:"azureResourceGraph"` } -func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureResourceGraphQuery, error) { +func (e *AzureResourceGraphDatasource) buildQueries(logger log.Logger, queries []backend.DataQuery, dsInfo types.DatasourceInfo) ([]*AzureResourceGraphQuery, error) { var azureResourceGraphQueries []*AzureResourceGraphQuery for _, query := range queries { @@ -93,14 +93,14 @@ func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, } azureResourceGraphTarget := queryJSONModel.AzureResourceGraph - azlog.Debug("AzureResourceGraph", "target", azureResourceGraphTarget) + logger.Debug("AzureResourceGraph", "target", azureResourceGraphTarget) resultFormat := azureResourceGraphTarget.ResultFormat if resultFormat == "" { resultFormat = "table" } - interpolatedQuery, err := macros.KqlInterpolate(query, dsInfo, azureResourceGraphTarget.Query) + interpolatedQuery, err := macros.KqlInterpolate(logger, query, dsInfo, azureResourceGraphTarget.Query) if err != nil { return nil, err @@ -118,7 +118,7 @@ func (e *AzureResourceGraphDatasource) buildQueries(queries []backend.DataQuery, return azureResourceGraphQueries, nil } -func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query *AzureResourceGraphQuery, dsInfo types.DatasourceInfo, client *http.Client, +func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, logger log.Logger, query *AzureResourceGraphQuery, dsInfo types.DatasourceInfo, client *http.Client, dsURL string, tracer tracing.Tracer) backend.DataResponse { dataResponse := backend.DataResponse{} @@ -156,7 +156,7 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * return dataResponse } - req, err := e.createRequest(ctx, dsInfo, reqBody, dsURL) + req, err := e.createRequest(ctx, logger, reqBody, dsURL) if err != nil { dataResponse.Error = err @@ -177,13 +177,13 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * tracer.Inject(ctx, req.Header, span) - azlog.Debug("AzureResourceGraph", "Request ApiURL", req.URL.String()) + logger.Debug("AzureResourceGraph", "Request ApiURL", req.URL.String()) res, err := client.Do(req) if err != nil { return dataResponseErrorWithExecuted(err) } - argResponse, err := e.unmarshalResponse(res) + argResponse, err := e.unmarshalResponse(logger, res) if err != nil { return dataResponseErrorWithExecuted(err) } @@ -224,10 +224,10 @@ func AddConfigLinks(frame data.Frame, dl string) data.Frame { return frame } -func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo types.DatasourceInfo, reqBody []byte, url string) (*http.Request, error) { +func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, logger log.Logger, reqBody []byte, url string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(reqBody)) if err != nil { - azlog.Debug("Failed to create request", "error", err) + logger.Debug("Failed to create request", "error", err) return nil, fmt.Errorf("%v: %w", "failed to create request", err) } req.URL.Path = "/" @@ -237,19 +237,19 @@ func (e *AzureResourceGraphDatasource) createRequest(ctx context.Context, dsInfo return req, nil } -func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (AzureResourceGraphResponse, error) { +func (e *AzureResourceGraphDatasource) unmarshalResponse(logger log.Logger, res *http.Response) (AzureResourceGraphResponse, error) { body, err := io.ReadAll(res.Body) if err != nil { return AzureResourceGraphResponse{}, err } defer func() { if err := res.Body.Close(); err != nil { - azlog.Warn("Failed to close response body", "err", err) + logger.Warn("Failed to close response body", "err", err) } }() if res.StatusCode/100 != 2 { - azlog.Debug("Request failed", "status", res.Status, "body", string(body)) + logger.Debug("Request failed", "status", res.Status, "body", string(body)) return AzureResourceGraphResponse{}, fmt.Errorf("%s. Azure Resource Graph error: %s", res.Status, string(body)) } @@ -258,7 +258,7 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az d.UseNumber() err = d.Decode(&data) if err != nil { - azlog.Debug("Failed to unmarshal azure resource graph response", "error", err, "status", res.Status, "body", string(body)) + logger.Debug("Failed to unmarshal azure resource graph response", "error", err, "status", res.Status, "body", string(body)) return AzureResourceGraphResponse{}, err } diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go index ca397a7d80d..a543388ab36 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource_test.go @@ -14,12 +14,16 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) +var logger = log.New("test") + func TestBuildingAzureResourceGraphQueries(t *testing.T) { datasource := &AzureResourceGraphDatasource{} fromStart := time.Date(2018, 3, 15, 13, 0, 0, 0, time.UTC).In(time.Local) @@ -70,7 +74,7 @@ func TestBuildingAzureResourceGraphQueries(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - queries, err := datasource.buildQueries(tt.queryModel, types.DatasourceInfo{}) + queries, err := datasource.buildQueries(logger, tt.queryModel, types.DatasourceInfo{}) tt.Err(t, err) if diff := cmp.Diff(tt.azureResourceGraphQueries, queries, cmpopts.IgnoreUnexported(simplejson.Json{})); diff != "" { t.Errorf("Result mismatch (-want +got):\n%s", diff) @@ -82,7 +86,6 @@ func TestBuildingAzureResourceGraphQueries(t *testing.T) { func TestAzureResourceGraphCreateRequest(t *testing.T) { ctx := context.Background() url := "http://ds" - dsInfo := types.DatasourceInfo{} tests := []struct { name string @@ -104,7 +107,7 @@ func TestAzureResourceGraphCreateRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ds := AzureResourceGraphDatasource{} - req, err := ds.createRequest(ctx, dsInfo, []byte{}, url) + req, err := ds.createRequest(ctx, logger, []byte{}, url) tt.Err(t, err) if req.URL.String() != tt.expectedURL { t.Errorf("Expecting %s, got %s", tt.expectedURL, req.URL.String()) @@ -157,7 +160,7 @@ func TestGetAzurePortalUrl(t *testing.T) { func TestUnmarshalResponse400(t *testing.T) { datasource := &AzureResourceGraphDatasource{} - res, err := datasource.unmarshalResponse(&http.Response{ + res, err := datasource.unmarshalResponse(logger, &http.Response{ StatusCode: 400, Status: "400 Bad Request", Body: io.NopCloser(strings.NewReader(("Azure Error Message"))), @@ -171,7 +174,7 @@ func TestUnmarshalResponse400(t *testing.T) { func TestUnmarshalResponse200Invalid(t *testing.T) { datasource := &AzureResourceGraphDatasource{} - res, err := datasource.unmarshalResponse(&http.Response{ + res, err := datasource.unmarshalResponse(logger, &http.Response{ StatusCode: 200, Status: "OK", Body: io.NopCloser(strings.NewReader(("Azure Data"))), @@ -186,7 +189,7 @@ func TestUnmarshalResponse200Invalid(t *testing.T) { func TestUnmarshalResponse200(t *testing.T) { datasource := &AzureResourceGraphDatasource{} - res, err2 := datasource.unmarshalResponse(&http.Response{ + res, err2 := datasource.unmarshalResponse(logger, &http.Response{ StatusCode: 200, Status: "OK", Body: io.NopCloser(strings.NewReader("{}")), diff --git a/pkg/tsdb/cloudmonitoring/annotation_query.go b/pkg/tsdb/cloudmonitoring/annotation_query.go index fa41080b6ce..603d478c71a 100644 --- a/pkg/tsdb/cloudmonitoring/annotation_query.go +++ b/pkg/tsdb/cloudmonitoring/annotation_query.go @@ -8,6 +8,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/grafana/pkg/infra/log" ) type annotationEvent struct { @@ -17,11 +19,11 @@ type annotationEvent struct { Text string } -func (s *Service) executeAnnotationQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo) ( +func (s *Service) executeAnnotationQuery(ctx context.Context, logger log.Logger, req *backend.QueryDataRequest, dsInfo datasourceInfo) ( *backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() - queries, err := s.buildQueryExecutors(req) + queries, err := s.buildQueryExecutors(logger, req) if err != nil { return resp, err } @@ -60,7 +62,7 @@ func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) transformAnnotationToFrame frame.AppendRow(a.Time, a.Title, a.Tags, a.Text) } result.Frames = append(result.Frames, frame) - slog.Info("anno", "len", len(annotations)) + timeSeriesQuery.logger.Info("anno", "len", len(annotations)) } func formatAnnotationText(annotationText string, pointValue string, metricType string, metricLabels map[string]string, resourceLabels map[string]string) string { diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go index 5098a66b6ce..fd3fa813e75 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go @@ -222,6 +222,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst // QueryData takes in the frontend queries, parses them into the CloudMonitoring query format // executes the queries against the CloudMonitoring API and parses the response into data frames func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + logger := slog.FromContext(ctx) resp := backend.NewQueryDataResponse() if len(req.Queries) == 0 { return resp, fmt.Errorf("query contains no queries") @@ -240,20 +241,20 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) switch model.Type { case "annotationQuery": - resp, err = s.executeAnnotationQuery(ctx, req, *dsInfo) + resp, err = s.executeAnnotationQuery(ctx, logger, req, *dsInfo) case "timeSeriesQuery": fallthrough default: - resp, err = s.executeTimeSeriesQuery(ctx, req, *dsInfo) + resp, err = s.executeTimeSeriesQuery(ctx, logger, req, *dsInfo) } return resp, err } -func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo datasourceInfo) ( +func (s *Service) executeTimeSeriesQuery(ctx context.Context, logger log.Logger, req *backend.QueryDataRequest, dsInfo datasourceInfo) ( *backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() - queryExecutors, err := s.buildQueryExecutors(req) + queryExecutors, err := s.buildQueryExecutors(logger, req) if err != nil { return resp, err } @@ -304,7 +305,7 @@ func queryModel(query backend.DataQuery) (grafanaQuery, error) { return q, nil } -func (s *Service) buildQueryExecutors(req *backend.QueryDataRequest) ([]cloudMonitoringQueryExecutor, error) { +func (s *Service) buildQueryExecutors(logger log.Logger, req *backend.QueryDataRequest) ([]cloudMonitoringQueryExecutor, error) { var cloudMonitoringQueryExecutors []cloudMonitoringQueryExecutor startTime := req.Queries[0].TimeRange.From endTime := req.Queries[0].TimeRange.To @@ -326,6 +327,7 @@ func (s *Service) buildQueryExecutors(req *backend.QueryDataRequest) ([]cloudMon cmtsf := &cloudMonitoringTimeSeriesFilter{ RefID: query.RefID, GroupBys: []string{}, + logger: logger, } switch q.QueryType { case metricQueryType: @@ -369,7 +371,7 @@ func (s *Service) buildQueryExecutors(req *backend.QueryDataRequest) ([]cloudMon cmtsf.Params = params if setting.Env == setting.Dev { - slog.Debug("CloudMonitoring request", "params", params) + logger.Debug("CloudMonitoring request", "params", params) } cloudMonitoringQueryExecutors = append(cloudMonitoringQueryExecutors, queryInterface) @@ -606,7 +608,7 @@ func calcBucketBound(bucketOptions cloudMonitoringBucketOptions, n int) string { return bucketBound } -func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { +func (s *Service) createRequest(logger log.Logger, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { u, err := url.Parse(dsInfo.url) if err != nil { return nil, err @@ -619,7 +621,7 @@ func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, pro } req, err := http.NewRequest(method, dsInfo.services[cloudMonitor].url, body) if err != nil { - slog.Error("Failed to create request", "error", err) + logger.Error("Failed to create request", "error", err) return nil, fmt.Errorf("failed to create request: %w", err) } @@ -636,7 +638,7 @@ func (s *Service) getDefaultProject(ctx context.Context, dsInfo datasourceInfo) return dsInfo.defaultProject, nil } -func unmarshalResponse(res *http.Response) (cloudMonitoringResponse, error) { +func unmarshalResponse(logger log.Logger, res *http.Response) (cloudMonitoringResponse, error) { body, err := io.ReadAll(res.Body) if err != nil { return cloudMonitoringResponse{}, err @@ -644,19 +646,19 @@ func unmarshalResponse(res *http.Response) (cloudMonitoringResponse, error) { defer func() { if err := res.Body.Close(); err != nil { - slog.Warn("Failed to close response body", "err", err) + logger.Warn("Failed to close response body", "err", err) } }() if res.StatusCode/100 != 2 { - slog.Error("Request failed", "status", res.Status, "body", string(body)) + logger.Error("Request failed", "status", res.Status, "body", string(body)) return cloudMonitoringResponse{}, fmt.Errorf("query failed: %s", string(body)) } var data cloudMonitoringResponse err = json.Unmarshal(body, &data) if err != nil { - slog.Error("Failed to unmarshal CloudMonitoring response", "error", err, "status", res.Status, "body", string(body)) + logger.Error("Failed to unmarshal CloudMonitoring response", "error", err, "status", res.Status, "body", string(body)) return cloudMonitoringResponse{}, fmt.Errorf("failed to unmarshal query response: %w", err) } diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go index 94b982f86a2..e20a9b9cc35 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring_test.go @@ -23,7 +23,7 @@ func TestCloudMonitoring(t *testing.T) { t.Run("Parse migrated queries from frontend and build Google Cloud Monitoring API queries", func(t *testing.T) { t.Run("and query has no aggregation set", func(t *testing.T) { - qes, err := service.buildQueryExecutors(baseReq()) + qes, err := service.buildQueryExecutors(slog, baseReq()) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -64,7 +64,7 @@ func TestCloudMonitoring(t *testing.T) { "filters": ["key", "=", "value", "AND", "key2", "=", "value2", "AND", "resource.type", "=", "another/resource/type"] }`) - qes, err := service.buildQueryExecutors(query) + qes, err := service.buildQueryExecutors(slog, query) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, 1, len(queries)) @@ -96,7 +96,7 @@ func TestCloudMonitoring(t *testing.T) { "filters": ["key", "=", "value", "AND", "key2", "=", "value2"] }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+1000s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -124,7 +124,7 @@ func TestCloudMonitoring(t *testing.T) { "filters": ["key", "=", "value", "AND", "key2", "=", "value2"] }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -158,7 +158,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "cloud-monitoring-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -173,7 +173,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "cloud-monitoring-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -188,7 +188,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "cloud-monitoring-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+300s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -203,7 +203,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "cloud-monitoring-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+3600s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -222,7 +222,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "stackdriver-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -252,7 +252,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "stackdriver-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+60s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -282,7 +282,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "stackdriver-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+300s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -312,7 +312,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "stackdriver-auto" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+3600s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -342,7 +342,7 @@ func TestCloudMonitoring(t *testing.T) { "alignmentPeriod": "+600s" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `+600s`, queries[0].Params["aggregation.alignmentPeriod"][0]) @@ -372,7 +372,7 @@ func TestCloudMonitoring(t *testing.T) { "view": "FULL" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -416,7 +416,7 @@ func TestCloudMonitoring(t *testing.T) { "view": "FULL" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -476,7 +476,7 @@ func TestCloudMonitoring(t *testing.T) { }, } t.Run("and query type is metrics", func(t *testing.T) { - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -523,7 +523,7 @@ func TestCloudMonitoring(t *testing.T) { "sloQuery": {} }`) - qes, err = service.buildQueryExecutors(req) + qes, err = service.buildQueryExecutors(slog, req) require.NoError(t, err) tqueries := make([]*cloudMonitoringTimeSeriesQuery, 0) for _, qi := range qes { @@ -554,7 +554,7 @@ func TestCloudMonitoring(t *testing.T) { "metricQuery": {} }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -582,7 +582,7 @@ func TestCloudMonitoring(t *testing.T) { "metricQuery": {} }`) - qes, err = service.buildQueryExecutors(req) + qes, err = service.buildQueryExecutors(slog, req) require.NoError(t, err) qqueries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, "ALIGN_NEXT_OLDER", qqueries[0].Params["aggregation.perSeriesAligner"][0]) @@ -605,7 +605,7 @@ func TestCloudMonitoring(t *testing.T) { "metricQuery": {} }`) - qes, err = service.buildQueryExecutors(req) + qes, err = service.buildQueryExecutors(slog, req) require.NoError(t, err) qqqueries := getCloudMonitoringQueriesFromInterface(t, qes) assert.Equal(t, `aggregation.alignmentPeriod=%2B60s&aggregation.perSeriesAligner=ALIGN_NEXT_OLDER&filter=select_slo_burn_rate%28%22projects%2Ftest-proj%2Fservices%2Ftest-service%2FserviceLevelObjectives%2Ftest-slo%22%2C+%221h%22%29&interval.endTime=2018-03-15T13%3A34%3A00Z&interval.startTime=2018-03-15T13%3A00%3A00Z`, qqqueries[0].Target) @@ -710,7 +710,7 @@ func TestCloudMonitoring(t *testing.T) { "view": "FULL" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -738,7 +738,7 @@ func TestCloudMonitoring(t *testing.T) { "preprocessor": "none" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -766,7 +766,7 @@ func TestCloudMonitoring(t *testing.T) { "preprocessor": "rate" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -792,7 +792,7 @@ func TestCloudMonitoring(t *testing.T) { "preprocessor": "rate" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -820,7 +820,7 @@ func TestCloudMonitoring(t *testing.T) { "preprocessor": "delta" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) @@ -846,7 +846,7 @@ func TestCloudMonitoring(t *testing.T) { "preprocessor": "delta" }`) - qes, err := service.buildQueryExecutors(req) + qes, err := service.buildQueryExecutors(slog, req) require.NoError(t, err) queries := getCloudMonitoringQueriesFromInterface(t, qes) diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 326783ee166..38fdd2c53a6 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -26,7 +26,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) doRequestFilterPage(ctx return cloudMonitoringResponse{}, err } - dnext, err := unmarshalResponse(res) + dnext, err := unmarshalResponse(timeSeriesFilter.logger, res) if err != nil { return cloudMonitoringResponse{}, err } @@ -45,9 +45,9 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) run(ctx context.Context dr.Error = err return dr, cloudMonitoringResponse{}, "", nil } - slog.Info("No project name set on query, using project name from datasource", "projectName", projectName) + timeSeriesFilter.logger.Info("No project name set on query, using project name from datasource", "projectName", projectName) } - r, err := s.createRequest(ctx, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries"), nil) + r, err := s.createRequest(timeSeriesFilter.logger, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries"), nil) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index e087f59155e..af0dbe4b6ed 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "go.opentelemetry.io/otel/attribute" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/tsdb/intervalv2" ) @@ -35,7 +36,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) appendGraphPeriod(req *ba return "" } -func doRequestQueryPage(requestBody map[string]interface{}, r *http.Request, dsInfo datasourceInfo) (cloudMonitoringResponse, error) { +func doRequestQueryPage(log log.Logger, requestBody map[string]interface{}, r *http.Request, dsInfo datasourceInfo) (cloudMonitoringResponse, error) { buf, err := json.Marshal(requestBody) if err != nil { return cloudMonitoringResponse{}, err @@ -46,7 +47,7 @@ func doRequestQueryPage(requestBody map[string]interface{}, r *http.Request, dsI return cloudMonitoringResponse{}, err } - dnext, err := unmarshalResponse(res) + dnext, err := unmarshalResponse(log, res) if err != nil { return cloudMonitoringResponse{}, err } @@ -65,7 +66,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, dr.Error = err return dr, cloudMonitoringResponse{}, "", nil } - slog.Info("No project name set on query, using project name from datasource", "projectName", projectName) + timeSeriesQuery.logger.Info("No project name set on query, using project name from datasource", "projectName", projectName) } timeSeriesQuery.Query += timeSeriesQuery.appendGraphPeriod(req) @@ -84,7 +85,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, requestBody := map[string]interface{}{ "query": timeSeriesQuery.Query, } - r, err := s.createRequest(ctx, &dsInfo, p, bytes.NewBuffer([]byte{})) + r, err := s.createRequest(timeSeriesQuery.logger, &dsInfo, p, bytes.NewBuffer([]byte{})) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil @@ -92,7 +93,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, tracer.Inject(ctx, r.Header, span) r = r.WithContext(ctx) - d, err := doRequestQueryPage(requestBody, r, dsInfo) + d, err := doRequestQueryPage(timeSeriesQuery.logger, requestBody, r, dsInfo) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil @@ -102,7 +103,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) run(ctx context.Context, "query": timeSeriesQuery.Query, "pageToken": d.NextPageToken, } - nextPage, err := doRequestQueryPage(requestBody, r, dsInfo) + nextPage, err := doRequestQueryPage(timeSeriesQuery.logger, requestBody, r, dsInfo) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil @@ -184,7 +185,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b metricName := formatLegendKeys(d.Key, defaultMetricName, seriesLabels, nil, &cloudMonitoringTimeSeriesFilter{ - ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, + ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, logger: timeSeriesQuery.logger, }) dataField := frame.Fields[1] dataField.Name = metricName @@ -218,7 +219,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []time.Time{}) valueField := data.NewField(data.TimeSeriesValueFieldName, nil, []float64{}) - frameName := formatLegendKeys(d.Key, defaultMetricName, nil, additionalLabels, &cloudMonitoringTimeSeriesFilter{ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy}) + frameName := formatLegendKeys(d.Key, defaultMetricName, nil, additionalLabels, &cloudMonitoringTimeSeriesFilter{ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, logger: timeSeriesQuery.logger}) valueField.Name = frameName valueField.Labels = seriesLabels setDisplayNameAsFieldName(valueField) @@ -246,7 +247,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b additionalLabels := data.Labels{"bucket": bucketBound} timeField := data.NewField(data.TimeSeriesTimeFieldName, nil, []time.Time{}) valueField := data.NewField(data.TimeSeriesValueFieldName, nil, []float64{}) - frameName := formatLegendKeys(d.Key, defaultMetricName, seriesLabels, additionalLabels, &cloudMonitoringTimeSeriesFilter{ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy}) + frameName := formatLegendKeys(d.Key, defaultMetricName, seriesLabels, additionalLabels, &cloudMonitoringTimeSeriesFilter{ProjectName: timeSeriesQuery.ProjectName, AliasBy: timeSeriesQuery.AliasBy, logger: timeSeriesQuery.logger}) valueField.Name = frameName valueField.Labels = seriesLabels setDisplayNameAsFieldName(valueField) @@ -340,7 +341,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseToAnnotations(queryR func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { u, err := url.Parse("https://console.cloud.google.com/monitoring/metrics-explorer") if err != nil { - slog.Error("Failed to generate deep link: unable to parse metrics explorer URL", "projectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) + timeSeriesQuery.logger.Error("Failed to generate deep link: unable to parse metrics explorer URL", "projectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) return "" } @@ -373,7 +374,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { blob, err := json.Marshal(pageState) if err != nil { - slog.Error("Failed to generate deep link", "pageState", pageState, "ProjectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) + timeSeriesQuery.logger.Error("Failed to generate deep link", "pageState", pageState, "ProjectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) return "" } @@ -382,7 +383,7 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { accountChooserURL, err := url.Parse("https://accounts.google.com/AccountChooser") if err != nil { - slog.Error("Failed to generate deep link: unable to parse account chooser URL", "ProjectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) + timeSeriesQuery.logger.Error("Failed to generate deep link: unable to parse account chooser URL", "ProjectName", timeSeriesQuery.ProjectName, "query", timeSeriesQuery.RefID) return "" } accountChooserQuery := accountChooserURL.Query() diff --git a/pkg/tsdb/cloudmonitoring/types.go b/pkg/tsdb/cloudmonitoring/types.go index 2ba070b6b17..0b09c652bea 100644 --- a/pkg/tsdb/cloudmonitoring/types.go +++ b/pkg/tsdb/cloudmonitoring/types.go @@ -6,6 +6,8 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" ) @@ -30,6 +32,7 @@ type ( Selector string Service string Slo string + logger log.Logger } // Used to build MQL queries @@ -41,6 +44,7 @@ type ( AliasBy string timeRange backend.TimeRange GraphPeriod string + logger log.Logger } metricQuery struct { From 9b8e3b96198685c8a1528015f6bce8be3602a11e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 13:39:51 +0000 Subject: [PATCH 042/926] Update dependency jest-fail-on-console to v3 (#58219) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a4c9207d9ab..9e5c9c69a7e 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,7 @@ "jest-canvas-mock": "2.4.0", "jest-date-mock": "1.0.8", "jest-environment-jsdom": "28.1.3", - "jest-fail-on-console": "2.4.2", + "jest-fail-on-console": "3.0.2", "jest-junit": "14.0.1", "jest-matcher-utils": "28.1.3", "lerna": "5.5.4", diff --git a/yarn.lock b/yarn.lock index 361cf87e574..f98404d7df2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21704,7 +21704,7 @@ __metadata: jest-canvas-mock: 2.4.0 jest-date-mock: 1.0.8 jest-environment-jsdom: 28.1.3 - jest-fail-on-console: 2.4.2 + jest-fail-on-console: 3.0.2 jest-junit: 14.0.1 jest-matcher-utils: 28.1.3 jquery: 3.6.1 @@ -24492,12 +24492,12 @@ __metadata: languageName: node linkType: hard -"jest-fail-on-console@npm:2.4.2": - version: 2.4.2 - resolution: "jest-fail-on-console@npm:2.4.2" +"jest-fail-on-console@npm:3.0.2": + version: 3.0.2 + resolution: "jest-fail-on-console@npm:3.0.2" dependencies: chalk: ^4.1.0 - checksum: 76c55424180640dff176cc89f39f43929a3d5a44d66af227d9117767bf32c2ec0da23712e345e15aec1caf4d14fb6626f897c23ca8643559ca069e18ed969186 + checksum: 3d14a72f38028317a3dd1d1d7118bf045b71ce7c7d815fa0be2e4303ebaee314e83f640a155c34a42a2e8819259f994ac4341d0e6743d049880b81aee27e10a0 languageName: node linkType: hard From a6f302648b9c67a492400c2db06fe092bac659ae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 13:49:09 +0000 Subject: [PATCH 043/926] Update dependency @wojtekmaj/enzyme-adapter-react-17 to v0.7.0 (#58233) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9e5c9c69a7e..d84a9062db8 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,7 @@ "@types/uuid": "8.3.4", "@typescript-eslint/eslint-plugin": "5.42.0", "@typescript-eslint/parser": "5.42.0", - "@wojtekmaj/enzyme-adapter-react-17": "0.6.7", + "@wojtekmaj/enzyme-adapter-react-17": "0.7.0", "autoprefixer": "10.4.13", "babel-jest": "28.1.3", "babel-loader": "9.1.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index a8cb495fc3e..a34caeb4f49 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -169,7 +169,7 @@ "@types/testing-library__react-hooks": "^3.2.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "8.3.4", - "@wojtekmaj/enzyme-adapter-react-17": "0.6.7", + "@wojtekmaj/enzyme-adapter-react-17": "0.7.0", "common-tags": "1.8.2", "css-loader": "6.7.1", "csstype": "3.1.1", diff --git a/yarn.lock b/yarn.lock index f98404d7df2..21c59d9e7d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4750,7 +4750,7 @@ __metadata: "@types/testing-library__react-hooks": ^3.2.0 "@types/tinycolor2": 1.4.3 "@types/uuid": 8.3.4 - "@wojtekmaj/enzyme-adapter-react-17": 0.6.7 + "@wojtekmaj/enzyme-adapter-react-17": 0.7.0 ansicolor: 1.1.100 calculate-size: 1.1.1 classnames: 2.3.2 @@ -12776,9 +12776,9 @@ __metadata: languageName: node linkType: hard -"@wojtekmaj/enzyme-adapter-react-17@npm:0.6.7": - version: 0.6.7 - resolution: "@wojtekmaj/enzyme-adapter-react-17@npm:0.6.7" +"@wojtekmaj/enzyme-adapter-react-17@npm:0.7.0": + version: 0.7.0 + resolution: "@wojtekmaj/enzyme-adapter-react-17@npm:0.7.0" dependencies: "@wojtekmaj/enzyme-adapter-utils": ^0.1.4 enzyme-shallow-equal: ^1.0.0 @@ -12790,7 +12790,7 @@ __metadata: enzyme: ^3.0.0 react: ^17.0.0-0 react-dom: ^17.0.0-0 - checksum: 5cd4c01adf0d778c5ef749964ae971422f0d445be11f9585de6a077edc32ea7ca74f14c240cf5f0848a8147596d84ed133820c91fc98d33f02b9dc6eee3fd158 + checksum: aa0f823753e3d5438044ed1fc23be2d5158f62dc73e95ae6e621a91e301b9be118b9effb9971345200b77e7cb213d36a1cdc20271e327101eb680e666b041df2 languageName: node linkType: hard @@ -21637,7 +21637,7 @@ __metadata: "@visx/shape": 2.12.2 "@visx/tooltip": 2.16.0 "@welldone-software/why-did-you-render": 7.0.1 - "@wojtekmaj/enzyme-adapter-react-17": 0.6.7 + "@wojtekmaj/enzyme-adapter-react-17": 0.7.0 angular: 1.8.3 angular-bindonce: 0.3.1 angular-route: 1.8.3 From 428dd540941df881fdbb25e9fbb37364a344b871 Mon Sep 17 00:00:00 2001 From: Emil Tullstedt Date: Fri, 4 Nov 2022 14:50:43 +0100 Subject: [PATCH 044/926] Chore: Upgrade Go to 1.19.3 (#58052) --- .drone.yml | 510 +++++++++++++++--------------- Dockerfile | 2 +- Dockerfile.ubuntu | 2 +- scripts/build/ci-build/Dockerfile | 2 +- scripts/drone/steps/lib.star | 6 +- 5 files changed, 261 insertions(+), 261 deletions(-) diff --git a/.drone.yml b/.drone.yml index b8a2ee5796c..414743f8b8c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -19,7 +19,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -28,7 +28,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build verify-drone @@ -73,20 +73,20 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -94,7 +94,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-frontend trigger: event: @@ -135,7 +135,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn run prettier:check @@ -146,7 +146,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: lint-frontend trigger: event: @@ -191,7 +191,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -200,7 +200,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -208,25 +208,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: event: @@ -273,12 +273,12 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update && apt-get install make @@ -287,7 +287,7 @@ steps: - wire-install environment: CGO_ENABLED: "1" - image: golang:1.19.2 + image: golang:1.19.3 name: lint-backend trigger: event: @@ -330,7 +330,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -339,7 +339,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -348,7 +348,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -356,18 +356,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" @@ -392,7 +392,7 @@ steps: from_secret: github_token_pr TEST_TAG: v0.0.0-test failure: ignore - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: trigger-test-release when: paths: @@ -419,7 +419,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -428,7 +428,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -437,7 +437,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -445,7 +445,7 @@ steps: - compile-build-cmd - yarn-install environment: null - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - . scripts/build/gpg-test-vars.sh && ./bin/build package --jobs 8 --edition oss @@ -456,7 +456,7 @@ steps: - build-frontend - build-frontend-packages environment: null - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package - commands: - ./scripts/grafana-server/start-server @@ -469,7 +469,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -546,7 +546,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -557,7 +557,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -635,7 +635,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -644,7 +644,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - echo $DRONE_RUNNER_NAME @@ -657,7 +657,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -665,13 +665,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -687,7 +687,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -703,7 +703,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: event: @@ -748,7 +748,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -759,7 +759,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - |- @@ -771,7 +771,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: codespell - commands: - yarn run prettier:checkDocs @@ -779,7 +779,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -821,13 +821,13 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build shellcheck depends_on: - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: shellcheck trigger: event: @@ -861,7 +861,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -872,7 +872,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - |- @@ -884,7 +884,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: codespell - commands: - yarn run prettier:checkDocs @@ -892,7 +892,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -936,20 +936,20 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -957,7 +957,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-frontend trigger: branch: main @@ -995,7 +995,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn run prettier:check @@ -1006,7 +1006,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: lint-frontend trigger: branch: main @@ -1048,7 +1048,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -1057,7 +1057,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1065,25 +1065,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: branch: main @@ -1123,12 +1123,12 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update && apt-get install make @@ -1137,7 +1137,7 @@ steps: - wire-install environment: CGO_ENABLED: "1" - image: golang:1.19.2 + image: golang:1.19.3 name: lint-backend - commands: - ./bin/build verify-drone @@ -1180,7 +1180,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1189,7 +1189,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -1198,7 +1198,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1206,25 +1206,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - ./bin/build build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1233,7 +1233,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1242,7 +1242,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1252,7 +1252,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -1270,7 +1270,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package - commands: - ./scripts/grafana-server/start-server @@ -1283,7 +1283,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -1360,7 +1360,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -1371,7 +1371,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -1415,7 +1415,7 @@ steps: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key failure: ignore - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: publish-frontend-metrics when: repo: @@ -1496,7 +1496,7 @@ steps: environment: NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: release-canary-npm-packages when: repo: @@ -1583,7 +1583,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1592,7 +1592,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - echo $DRONE_RUNNER_NAME @@ -1605,7 +1605,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1613,13 +1613,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -1635,7 +1635,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -1651,7 +1651,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: branch: main @@ -1697,7 +1697,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -1874,7 +1874,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1885,32 +1885,32 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build build-backend --jobs 8 --edition oss ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss ${DRONE_TAG} @@ -1919,7 +1919,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss ${DRONE_TAG} @@ -1928,7 +1928,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1938,7 +1938,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --sign ${DRONE_TAG} @@ -1956,14 +1956,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -2002,7 +2002,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -2079,7 +2079,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -2139,7 +2139,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: store-npm-packages trigger: event: @@ -2179,20 +2179,20 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2200,7 +2200,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-frontend trigger: event: @@ -2239,7 +2239,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2248,7 +2248,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2256,25 +2256,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: event: @@ -2326,7 +2326,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2341,7 +2341,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2349,13 +2349,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -2371,7 +2371,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -2387,7 +2387,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: event: @@ -2432,7 +2432,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -2489,7 +2489,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2504,7 +2504,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2520,7 +2520,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -2528,19 +2528,19 @@ steps: - init-enterprise environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2550,7 +2550,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2559,14 +2559,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2575,7 +2575,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2584,7 +2584,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -2594,14 +2594,14 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise --sign ${DRONE_TAG} @@ -2620,14 +2620,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -2667,7 +2667,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -2775,7 +2775,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 @@ -2797,7 +2797,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: store-npm-packages - commands: - ./bin/grabpl upload-packages --edition enterprise2 @@ -2849,7 +2849,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2865,7 +2865,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -2873,7 +2873,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2881,14 +2881,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2897,7 +2897,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-frontend trigger: event: @@ -2934,11 +2934,11 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2956,7 +2956,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -2968,7 +2968,7 @@ steps: - init-enterprise environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2978,7 +2978,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2987,25 +2987,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: event: @@ -3063,7 +3063,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3078,7 +3078,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3094,7 +3094,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3104,7 +3104,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3113,13 +3113,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -3135,7 +3135,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -3151,7 +3151,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -3160,7 +3160,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -3169,7 +3169,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: memcached-integration-tests trigger: event: @@ -3214,7 +3214,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -3289,7 +3289,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3298,7 +3298,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build artifacts docker fetch --edition oss @@ -3377,7 +3377,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3386,7 +3386,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build artifacts docker fetch --edition enterprise @@ -3448,7 +3448,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3457,7 +3457,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build artifacts docker fetch --edition enterprise @@ -3519,7 +3519,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3561,7 +3561,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3603,14 +3603,14 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - ./bin/grabpl artifacts npm retrieve --tag v${TAG} @@ -3632,7 +3632,7 @@ steps: NPM_TOKEN: from_secret: npm_token failure: ignore - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: release-npm-packages trigger: event: @@ -3664,7 +3664,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3673,7 +3673,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - depends_on: - grabpl @@ -3759,7 +3759,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3768,7 +3768,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - depends_on: - grabpl @@ -3851,7 +3851,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3862,7 +3862,7 @@ steps: environment: GCP_KEY: from_secret: gcp_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: artifacts-page trigger: event: @@ -3896,7 +3896,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3907,32 +3907,32 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -3941,7 +3941,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -3950,7 +3950,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -3960,7 +3960,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -3978,14 +3978,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -4024,7 +4024,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -4101,7 +4101,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -4173,20 +4173,20 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4194,7 +4194,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-frontend trigger: ref: @@ -4230,7 +4230,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -4239,7 +4239,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4247,25 +4247,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: ref: @@ -4314,7 +4314,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4329,7 +4329,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4337,13 +4337,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -4359,7 +4359,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -4375,7 +4375,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: ref: @@ -4417,7 +4417,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -4467,7 +4467,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4482,7 +4482,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -4497,7 +4497,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -4505,19 +4505,19 @@ steps: - init-enterprise environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -4527,7 +4527,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4536,14 +4536,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4552,7 +4552,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4561,7 +4561,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -4571,7 +4571,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} @@ -4579,7 +4579,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4599,14 +4599,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -4646,7 +4646,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -4761,7 +4761,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 @@ -4821,7 +4821,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -4836,7 +4836,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -4844,7 +4844,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4852,14 +4852,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4868,7 +4868,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-frontend trigger: ref: @@ -4902,11 +4902,11 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4923,7 +4923,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -4935,7 +4935,7 @@ steps: - init-enterprise environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -4945,7 +4945,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4954,25 +4954,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: ref: @@ -5027,7 +5027,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -5042,7 +5042,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5057,7 +5057,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -5067,7 +5067,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5076,13 +5076,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -5098,7 +5098,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -5114,7 +5114,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -5123,7 +5123,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -5132,7 +5132,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.3 + image: grafana/build-container:1.6.4 name: memcached-integration-tests trigger: ref: @@ -5174,7 +5174,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.15/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -5361,7 +5361,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.19.2 + image: golang:1.19.3 name: compile-build-cmd - commands: - ./bin/build publish grafana-com --edition oss @@ -5446,6 +5446,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: c05242e46e10d9e2af78c038a72879351ab553787b2732c8afaf32c706e45096 +hmac: 7a173a96edd8b0495105d526b95121599fe2f7fba715bdf2a96073a2af5eca7d ... diff --git a/Dockerfile b/Dockerfile index a782bfe315f..1032ba60ae7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.19.2-alpine3.15 as go-builder +FROM golang:1.19.3-alpine3.15 as go-builder RUN apk add --no-cache gcc g++ make diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index c66e4913296..077d97a0c99 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -21,7 +21,7 @@ COPY emails emails ENV NODE_ENV production RUN yarn build -FROM golang:1.19.2 AS go-builder +FROM golang:1.19.3 AS go-builder WORKDIR /src/grafana diff --git a/scripts/build/ci-build/Dockerfile b/scripts/build/ci-build/Dockerfile index 3c389d94360..89b48b10d48 100644 --- a/scripts/build/ci-build/Dockerfile +++ b/scripts/build/ci-build/Dockerfile @@ -102,7 +102,7 @@ RUN rm dockerize-linux-amd64-v${DOCKERIZE_VERSION}.tar.gz # Use old Debian (LTS into 2024) in order to ensure binary compatibility with older glibc's. FROM debian:buster-20220822 -ENV GOVERSION=1.19.2 \ +ENV GOVERSION=1.19.3 \ PATH=/usr/local/go/bin:$PATH \ GOPATH=/go \ NODEVERSION=16.14.0-1nodesource1 \ diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index b15ca337fba..9e0bdf78645 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,14 +1,14 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') -grabpl_version = 'v3.0.15' -build_image = 'grafana/build-container:1.6.3' +grabpl_version = 'v3.0.16' +build_image = 'grafana/build-container:1.6.4' publish_image = 'grafana/grafana-ci-deploy:1.3.3' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' alpine_image = 'alpine:3.15.6' curl_image = 'byrnedo/alpine-curl:0.1.8' windows_image = 'mcr.microsoft.com/windows:1809' wix_image = 'grafana/ci-wix:0.1.1' -go_image = 'golang:1.19.2' +go_image = 'golang:1.19.3' disable_tests = False trigger_oss = { From 9ea6a430890ced15d73720ca60a3c0a0d834706d Mon Sep 17 00:00:00 2001 From: Dan Cech Date: Fri, 4 Nov 2022 10:14:21 -0400 Subject: [PATCH 045/926] Build: clean up and document integration test convention (#58170) * clean up and document integration test convention * clarify integration test conventions * clean up integration tests that don't follow convention * mark testIntegration* functions as helpers to avoid confusion --- contribute/style-guides/backend.md | 17 +++++++++++ .../manager/manager_integration_test.go | 5 ++-- .../resourcepermissions/store_test.go | 15 ++++++++++ .../apikey/apikeyimpl/sqlx_store_test.go | 3 ++ pkg/services/apikey/apikeyimpl/store_test.go | 5 ++-- .../apikey/apikeyimpl/xorm_store_test.go | 3 ++ .../dashverimpl/sqlx_store_test.go | 3 ++ .../dashverimpl/store_test.go | 5 ++-- .../dashverimpl/xorm_store_test.go | 3 ++ .../folder/folderimpl/sqlstore_test.go | 18 +++++++++++ pkg/services/ngalert/store/alert_rule_test.go | 6 ++++ .../ngalert/store/provisioning_store_test.go | 3 ++ .../playlist/playlistimpl/sqlx_store_test.go | 3 ++ .../playlist/playlistimpl/store_test.go | 4 +-- .../playlist/playlistimpl/xorm_store_test.go | 3 ++ .../preference/prefimpl/sqlx_store_test.go | 3 ++ .../preference/prefimpl/store_test.go | 5 ++-- .../preference/prefimpl/xorm_store_test.go | 3 ++ .../publicdashboards/api/query_test.go | 3 ++ .../database/database_test.go | 30 +++++++++++++++++++ .../tests/querylibrary_integration_test.go | 4 +-- pkg/services/sqlstore/bulk_test.go | 2 +- pkg/services/star/starimpl/sqlx_store_test.go | 3 ++ pkg/services/star/starimpl/store_test.go | 5 ++-- pkg/services/star/starimpl/xorm_store_test.go | 3 ++ .../object/tests/server_integration_test.go | 2 +- pkg/services/tag/tagimpl/sqlx_store_test.go | 3 ++ pkg/services/tag/tagimpl/store_test.go | 5 ++-- pkg/services/tag/tagimpl/xorm_store_test.go | 3 ++ .../api/elasticsearch/elasticsearch_test.go | 3 ++ pkg/tests/api/graphite/graphite_test.go | 3 ++ pkg/tests/api/influxdb/influxdb_test.go | 3 ++ pkg/tests/api/loki/loki_test.go | 3 ++ pkg/tests/api/opentdsb/opentdsb_test.go | 3 ++ pkg/tests/api/prometheus/prometheus_test.go | 6 ++++ pkg/tsdb/postgres/locker_test.go | 2 +- 36 files changed, 168 insertions(+), 25 deletions(-) diff --git a/contribute/style-guides/backend.md b/contribute/style-guides/backend.md index 8783e69189a..a10f05bd646 100644 --- a/contribute/style-guides/backend.md +++ b/contribute/style-guides/backend.md @@ -40,6 +40,23 @@ The majority of our tests uses [GoConvey](http://goconvey.co/) but that's someth In the `sqlstore` package we do database operations in tests and while some might say that's not suited for unit tests. We think they are fast enough and provide a lot of value. +### Integration Tests + +We run unit and integration tests separately, to help keep our CI pipeline running smoothly and provide a better developer experience. + +To properly mark a test as being an integration test, you must format your test function definition as follows, with the function name starting with `TestIntegration` and the check for `testing.Short()`: + +``` +func TestIntegrationFoo(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + // function body +} +``` + +If you do not follow this convention, your integration test may be run twice or not run at all. + ### Assertions Use respectively [`assert.*`](https://github.com/stretchr/testify#assert-package) functions to make assertions that diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index 2a7e0f9e519..8977ba99036 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -49,8 +49,9 @@ import ( ) func TestIntegrationPluginManager(t *testing.T) { - t.Helper() - + if testing.Short() { + t.Skip("skipping integration test") + } staticRootPath, err := filepath.Abs("../../../public/") require.NoError(t, err) diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index 434507bc341..16694fb7bf4 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -28,6 +28,9 @@ type setUserResourcePermissionTest struct { } func TestIntegrationStore_SetUserResourcePermission(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } tests := []setUserResourcePermissionTest{ { desc: "should set resource permission for user", @@ -110,6 +113,9 @@ type setTeamResourcePermissionTest struct { } func TestIntegrationStore_SetTeamResourcePermission(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } tests := []setTeamResourcePermissionTest{ { desc: "should add new resource permission for team", @@ -195,6 +201,9 @@ type setBuiltInResourcePermissionTest struct { } func TestIntegrationStore_SetBuiltInResourcePermission(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } tests := []setBuiltInResourcePermissionTest{ { desc: "should add new resource permission for builtin role", @@ -276,6 +285,9 @@ type setResourcePermissionsTest struct { } func TestIntegrationStore_SetResourcePermissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } tests := []setResourcePermissionsTest{ { desc: "should set all permissions provided", @@ -345,6 +357,9 @@ type getResourcePermissionsTest struct { } func TestIntegrationStore_GetResourcePermissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } tests := []getResourcePermissionsTest{ { desc: "should return permissions for resource id", diff --git a/pkg/services/apikey/apikeyimpl/sqlx_store_test.go b/pkg/services/apikey/apikeyimpl/sqlx_store_test.go index a5de59e07e9..42435ecfc58 100644 --- a/pkg/services/apikey/apikeyimpl/sqlx_store_test.go +++ b/pkg/services/apikey/apikeyimpl/sqlx_store_test.go @@ -8,6 +8,9 @@ import ( ) func TestIntegrationSQLxApiKeyDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationApiKeyDataAccess(t, func(ss db.DB, cfg *setting.Cfg) store { return &sqlxStore{sess: ss.GetSqlxSession(), cfg: cfg} }) diff --git a/pkg/services/apikey/apikeyimpl/store_test.go b/pkg/services/apikey/apikeyimpl/store_test.go index 2385c281ae1..938daca0e9a 100644 --- a/pkg/services/apikey/apikeyimpl/store_test.go +++ b/pkg/services/apikey/apikeyimpl/store_test.go @@ -53,9 +53,8 @@ func seedApiKeys(t *testing.T, store store, num int) { } func testIntegrationApiKeyDataAccess(t *testing.T, fn getStore) { - if testing.Short() { - t.Skip("skipping integration test") - } + t.Helper() + mockTimeNow() defer resetTimeNow() diff --git a/pkg/services/apikey/apikeyimpl/xorm_store_test.go b/pkg/services/apikey/apikeyimpl/xorm_store_test.go index 79c80a44746..e0b3d268497 100644 --- a/pkg/services/apikey/apikeyimpl/xorm_store_test.go +++ b/pkg/services/apikey/apikeyimpl/xorm_store_test.go @@ -8,6 +8,9 @@ import ( ) func TestIntegrationXORMApiKeyDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationApiKeyDataAccess(t, func(ss db.DB, cfg *setting.Cfg) store { return &sqlStore{db: ss, cfg: cfg} }) diff --git a/pkg/services/dashboardversion/dashverimpl/sqlx_store_test.go b/pkg/services/dashboardversion/dashverimpl/sqlx_store_test.go index 5ea81d7106c..59ffe69e946 100644 --- a/pkg/services/dashboardversion/dashverimpl/sqlx_store_test.go +++ b/pkg/services/dashboardversion/dashverimpl/sqlx_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationSQLxGetDashboardVersion(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationGetDashboardVersion(t, func(ss db.DB) store { return &sqlxStore{ sess: ss.GetSqlxSession(), diff --git a/pkg/services/dashboardversion/dashverimpl/store_test.go b/pkg/services/dashboardversion/dashverimpl/store_test.go index 8a3837bf394..8ee0fcb6b41 100644 --- a/pkg/services/dashboardversion/dashverimpl/store_test.go +++ b/pkg/services/dashboardversion/dashverimpl/store_test.go @@ -19,9 +19,8 @@ import ( type getStore func(db.DB) store func testIntegrationGetDashboardVersion(t *testing.T, fn getStore) { - if testing.Short() { - t.Skip("skipping integration test") - } + t.Helper() + ss := db.InitTestDB(t) dashVerStore := fn(ss) diff --git a/pkg/services/dashboardversion/dashverimpl/xorm_store_test.go b/pkg/services/dashboardversion/dashverimpl/xorm_store_test.go index dc3cd7a681c..dfc96dacff0 100644 --- a/pkg/services/dashboardversion/dashverimpl/xorm_store_test.go +++ b/pkg/services/dashboardversion/dashverimpl/xorm_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationXORMGetDashboardVersion(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationGetDashboardVersion(t, func(ss db.DB) store { return &sqlStore{ db: ss, diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 57fe4c7a793..85c1dfaf04e 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -18,6 +18,9 @@ import ( ) func TestIntegrationCreate(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) @@ -153,6 +156,9 @@ func TestIntegrationCreate(t *testing.T) { } func TestIntegrationDelete(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) @@ -198,6 +204,9 @@ func TestIntegrationDelete(t *testing.T) { } func TestIntegrationUpdate(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) @@ -300,6 +309,9 @@ func TestIntegrationUpdate(t *testing.T) { } func TestIntegrationGet(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) @@ -378,6 +390,9 @@ func TestIntegrationGet(t *testing.T) { } func TestIntegrationGetParents(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) @@ -439,6 +454,9 @@ func TestIntegrationGetParents(t *testing.T) { } func TestIntegrationGetChildren(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 3df14755348..4fb12c06b66 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -18,6 +18,9 @@ import ( ) func TestIntegrationUpdateAlertRules(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } sqlStore := db.InitTestDB(t) store := DBstore{ SQLStore: sqlStore, @@ -94,6 +97,9 @@ func withIntervalMatching(baseInterval time.Duration) func(*models.AlertRule) { } func TestIntegration_getFilterByOrgsString(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testCases := []struct { testName string orgs map[int64]struct{} diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index 356079ae022..e09a9bde51a 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -15,6 +15,9 @@ import ( const testAlertingIntervalSeconds = 10 func TestIntegrationProvisioningStore(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } store := createProvisioningStoreSut(tests.SetupTestEnv(t, testAlertingIntervalSeconds)) t.Run("Default provenance of a known type is None", func(t *testing.T) { diff --git a/pkg/services/playlist/playlistimpl/sqlx_store_test.go b/pkg/services/playlist/playlistimpl/sqlx_store_test.go index a6f1c582bb9..5b77bd5b850 100644 --- a/pkg/services/playlist/playlistimpl/sqlx_store_test.go +++ b/pkg/services/playlist/playlistimpl/sqlx_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationSQLxPlaylistDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationPlaylistDataAccess(t, func(ss db.DB) store { return &sqlxStore{sess: ss.GetSqlxSession()} }) diff --git a/pkg/services/playlist/playlistimpl/store_test.go b/pkg/services/playlist/playlistimpl/store_test.go index 69e2d360949..1bb2d82d0ba 100644 --- a/pkg/services/playlist/playlistimpl/store_test.go +++ b/pkg/services/playlist/playlistimpl/store_test.go @@ -13,9 +13,7 @@ import ( type getStore func(db.DB) store func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) { - if testing.Short() { - t.Skip("skipping integration test") - } + t.Helper() ss := db.InitTestDB(t) playlistStore := fn(ss) diff --git a/pkg/services/playlist/playlistimpl/xorm_store_test.go b/pkg/services/playlist/playlistimpl/xorm_store_test.go index 47ccf4adf1f..8f3b071b08a 100644 --- a/pkg/services/playlist/playlistimpl/xorm_store_test.go +++ b/pkg/services/playlist/playlistimpl/xorm_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationXormPlaylistDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationPlaylistDataAccess(t, func(ss db.DB) store { return &sqlStore{db: ss} }) diff --git a/pkg/services/preference/prefimpl/sqlx_store_test.go b/pkg/services/preference/prefimpl/sqlx_store_test.go index 70ab8f5a5c0..df6027e84ce 100644 --- a/pkg/services/preference/prefimpl/sqlx_store_test.go +++ b/pkg/services/preference/prefimpl/sqlx_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationSQLxPreferencesDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationPreferencesDataAccess(t, func(ss db.DB) store { return &sqlxStore{sess: ss.GetSqlxSession()} }) diff --git a/pkg/services/preference/prefimpl/store_test.go b/pkg/services/preference/prefimpl/store_test.go index 7df5f666039..5aaf283e32f 100644 --- a/pkg/services/preference/prefimpl/store_test.go +++ b/pkg/services/preference/prefimpl/store_test.go @@ -16,9 +16,8 @@ import ( type getStore func(db.DB) store func testIntegrationPreferencesDataAccess(t *testing.T, fn getStore) { - if testing.Short() { - t.Skip("skipping integration test") - } + t.Helper() + ss := db.InitTestDB(t) prefStore := fn(ss) orgNavbarPreferences := pref.NavbarPreference{ diff --git a/pkg/services/preference/prefimpl/xorm_store_test.go b/pkg/services/preference/prefimpl/xorm_store_test.go index 61f32b82a3b..9c26ad9f00d 100644 --- a/pkg/services/preference/prefimpl/xorm_store_test.go +++ b/pkg/services/preference/prefimpl/xorm_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationXORMPreferencesDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationPreferencesDataAccess(t, func(ss db.DB) store { return &sqlStore{db: ss} }) diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index a586f514e0d..92b87dec2ed 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -253,6 +253,9 @@ func getValidQueryPath(accessToken string) string { } func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } db := db.InitTestDB(t) cacheService := datasourcesService.ProvideCacheService(localcache.ProvideService(), db) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 4a505a76117..6c66764b304 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -31,6 +31,9 @@ func TestLogPrefix(t *testing.T) { } func TestIntegrationListPublicDashboard(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } sqlStore, cfg := db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore := ProvideStore(sqlStore) @@ -64,6 +67,9 @@ func TestIntegrationListPublicDashboard(t *testing.T) { } func TestIntegrationFindDashboard(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -88,6 +94,9 @@ func TestIntegrationFindDashboard(t *testing.T) { } func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -155,6 +164,9 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { } func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -214,6 +226,9 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { } func TestIntegrationFindByDashboardUid(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -276,6 +291,9 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { } func TestIntegrationFindByAccessToken(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -339,6 +357,9 @@ func TestIntegrationFindByAccessToken(t *testing.T) { } func TestIntegrationCreatePublicDashboard(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -406,6 +427,9 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { } func TestIntegrationUpdatePublicDashboard(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -497,6 +521,9 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { } func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore @@ -563,6 +590,9 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { } func TestIntegrationDelete(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } var sqlStore db.DB var cfg *setting.Cfg var dashboardStore *dashboardsDB.DashboardStore diff --git a/pkg/services/querylibrary/tests/querylibrary_integration_test.go b/pkg/services/querylibrary/tests/querylibrary_integration_test.go index d8dc7321578..b1d87726cb5 100644 --- a/pkg/services/querylibrary/tests/querylibrary_integration_test.go +++ b/pkg/services/querylibrary/tests/querylibrary_integration_test.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/grafanads" ) -func TestCreateAndDelete(t *testing.T) { +func TestIntegrationCreateAndDelete(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -123,7 +123,7 @@ func createQuery(t *testing.T, ctx context.Context, testCtx testContext) string return search[0].uid } -func TestDashboardGetWithLatestSavedQueries(t *testing.T) { +func TestIntegrationDashboardGetWithLatestSavedQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } diff --git a/pkg/services/sqlstore/bulk_test.go b/pkg/services/sqlstore/bulk_test.go index 7163645e5b4..e4abba3d116 100644 --- a/pkg/services/sqlstore/bulk_test.go +++ b/pkg/services/sqlstore/bulk_test.go @@ -60,7 +60,7 @@ func TestBatching(t *testing.T) { }) } -func TestBulkOps(t *testing.T) { +func TestIntegrationBulkOps(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } diff --git a/pkg/services/star/starimpl/sqlx_store_test.go b/pkg/services/star/starimpl/sqlx_store_test.go index 87608f93aae..3d9045e7475 100644 --- a/pkg/services/star/starimpl/sqlx_store_test.go +++ b/pkg/services/star/starimpl/sqlx_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationSQLxUserStarsDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationUserStarsDataAccess(t, func(ss db.DB) store { return &sqlxStore{sess: ss.GetSqlxSession()} }) diff --git a/pkg/services/star/starimpl/store_test.go b/pkg/services/star/starimpl/store_test.go index e1674466374..52bb12cfde4 100644 --- a/pkg/services/star/starimpl/store_test.go +++ b/pkg/services/star/starimpl/store_test.go @@ -13,9 +13,8 @@ import ( type getStore func(db.DB) store func testIntegrationUserStarsDataAccess(t *testing.T, fn getStore) { - if testing.Short() { - t.Skip("skipping integration test") - } + t.Helper() + t.Run("Testing User Stars Data Access", func(t *testing.T) { ss := db.InitTestDB(t) starStore := fn(ss) diff --git a/pkg/services/star/starimpl/xorm_store_test.go b/pkg/services/star/starimpl/xorm_store_test.go index 4df1fa18f7a..d4963f44b0e 100644 --- a/pkg/services/star/starimpl/xorm_store_test.go +++ b/pkg/services/star/starimpl/xorm_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationXormUserStarsDataAccess(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationUserStarsDataAccess(t, func(ss db.DB) store { return &sqlStore{db: ss} }) diff --git a/pkg/services/store/object/tests/server_integration_test.go b/pkg/services/store/object/tests/server_integration_test.go index 8ee66e25bfc..bc430d0e0ee 100644 --- a/pkg/services/store/object/tests/server_integration_test.go +++ b/pkg/services/store/object/tests/server_integration_test.go @@ -112,7 +112,7 @@ func requireVersionMatch(t *testing.T, obj *object.ObjectVersionInfo, m objectVe require.True(t, len(mismatches) == 0, mismatches) } -func TestObjectServer(t *testing.T) { +func TestIntegrationObjectServer(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } diff --git a/pkg/services/tag/tagimpl/sqlx_store_test.go b/pkg/services/tag/tagimpl/sqlx_store_test.go index 022aae3f00d..b67846a8f58 100644 --- a/pkg/services/tag/tagimpl/sqlx_store_test.go +++ b/pkg/services/tag/tagimpl/sqlx_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationSQLxSavingTags(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationSavingTags(t, func(ss db.DB) store { return &sqlxStore{sess: ss.GetSqlxSession()} }) diff --git a/pkg/services/tag/tagimpl/store_test.go b/pkg/services/tag/tagimpl/store_test.go index 5f52ba70370..0b865587ef5 100644 --- a/pkg/services/tag/tagimpl/store_test.go +++ b/pkg/services/tag/tagimpl/store_test.go @@ -13,9 +13,8 @@ import ( type getStore func(db.DB) store func testIntegrationSavingTags(t *testing.T, fn getStore) { - if testing.Short() { - t.Skip("skipping integration test") - } + t.Helper() + ss := db.InitTestDB(t) store := fn(ss) tagPairs := []*tag.Tag{ diff --git a/pkg/services/tag/tagimpl/xorm_store_test.go b/pkg/services/tag/tagimpl/xorm_store_test.go index c8257adca2c..65ea25f4bb8 100644 --- a/pkg/services/tag/tagimpl/xorm_store_test.go +++ b/pkg/services/tag/tagimpl/xorm_store_test.go @@ -7,6 +7,9 @@ import ( ) func TestIntegrationXormSavingTags(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } testIntegrationSavingTags(t, func(ss db.DB) store { return &sqlStore{db: ss} }) diff --git a/pkg/tests/api/elasticsearch/elasticsearch_test.go b/pkg/tests/api/elasticsearch/elasticsearch_test.go index 5dc7ffae8b4..e0d45eb1b0e 100644 --- a/pkg/tests/api/elasticsearch/elasticsearch_test.go +++ b/pkg/tests/api/elasticsearch/elasticsearch_test.go @@ -21,6 +21,9 @@ import ( ) func TestIntegrationElasticsearch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, }) diff --git a/pkg/tests/api/graphite/graphite_test.go b/pkg/tests/api/graphite/graphite_test.go index 22d9412ee04..7e84eef8de7 100644 --- a/pkg/tests/api/graphite/graphite_test.go +++ b/pkg/tests/api/graphite/graphite_test.go @@ -21,6 +21,9 @@ import ( ) func TestIntegrationGraphite(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, }) diff --git a/pkg/tests/api/influxdb/influxdb_test.go b/pkg/tests/api/influxdb/influxdb_test.go index 1ee2179f91a..de1cd72aef6 100644 --- a/pkg/tests/api/influxdb/influxdb_test.go +++ b/pkg/tests/api/influxdb/influxdb_test.go @@ -21,6 +21,9 @@ import ( ) func TestIntegrationInflux(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, }) diff --git a/pkg/tests/api/loki/loki_test.go b/pkg/tests/api/loki/loki_test.go index 3cdd48130c0..670cec5730b 100644 --- a/pkg/tests/api/loki/loki_test.go +++ b/pkg/tests/api/loki/loki_test.go @@ -21,6 +21,9 @@ import ( ) func TestIntegrationLoki(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, }) diff --git a/pkg/tests/api/opentdsb/opentdsb_test.go b/pkg/tests/api/opentdsb/opentdsb_test.go index 2886799c120..87dc100c524 100644 --- a/pkg/tests/api/opentdsb/opentdsb_test.go +++ b/pkg/tests/api/opentdsb/opentdsb_test.go @@ -21,6 +21,9 @@ import ( ) func TestIntegrationOpenTSDB(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, }) diff --git a/pkg/tests/api/prometheus/prometheus_test.go b/pkg/tests/api/prometheus/prometheus_test.go index c8db8a4fe11..b86835ebf9a 100644 --- a/pkg/tests/api/prometheus/prometheus_test.go +++ b/pkg/tests/api/prometheus/prometheus_test.go @@ -21,6 +21,9 @@ import ( ) func TestIntegrationPrometheusBuffered(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, }) @@ -104,6 +107,9 @@ func TestIntegrationPrometheusBuffered(t *testing.T) { } func TestIntegrationPrometheusClient(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"prometheusStreamingJSONParser"}, }) diff --git a/pkg/tsdb/postgres/locker_test.go b/pkg/tsdb/postgres/locker_test.go index 87d8b27dc8a..b1dc64f0351 100644 --- a/pkg/tsdb/postgres/locker_test.go +++ b/pkg/tsdb/postgres/locker_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestLocker(t *testing.T) { +func TestIntegrationLocker(t *testing.T) { if testing.Short() { t.Skip("Tests with Sleep") } From da9c646f242fd16ba8a789fa5d1c50094d761ae7 Mon Sep 17 00:00:00 2001 From: Dan Cech Date: Fri, 4 Nov 2022 10:20:08 -0400 Subject: [PATCH 046/926] Build: add explicit build step for go codeql (#58195) * add explicit build step for go codeql * support workflow_dispatch for codeql checks * syntax fix * enable on push to codeql-go branch * test * use go version from go.mod * explicitly set go version * tidy up, add workflow_dispatch support to all codeql actions --- .github/workflows/codeql-analysis.yml | 13 +++++++++++++ .github/workflows/pr-codeql-analysis-go.yml | 11 +++++++++++ .github/workflows/pr-codeql-analysis-javascript.yml | 1 + .github/workflows/pr-codeql-analysis-python.yml | 1 + go.mod | 2 +- 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c563770cff2..3fceb0d3545 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -6,6 +6,7 @@ name: "CodeQL" on: + workflow_dispatch: push: branches: [main, v1.8.x, v2.0.x, v2.1.x, v2.6.x, v3.0.x, v3.1.x, v4.0.x, v4.1.x, v4.2.x, v4.3.x, v4.4.x, v4.5.x, v4.6.x, v4.7.x, v5.0.x, v5.1.x, v5.2.x, v5.3.x, v5.4.x, v6.0.x, v6.1.x, v6.2.x, v6.3.x, v6.4.x, v6.5.x, v6.6.x, v6.7.x, v7.0.x, v7.1.x, v7.2.x] paths-ignore: @@ -39,6 +40,12 @@ jobs: # a pull request then we can checkout the head. fetch-depth: 2 + - if: matrix.language == 'go' + name: Set go version + uses: actions/setup-go@v3 + with: + go-version: '1.19.2' + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 @@ -49,5 +56,11 @@ jobs: # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main + - if: matrix.language == 'go' + name: Build go files + run: | + go mod verify + make build-go + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/pr-codeql-analysis-go.yml b/.github/workflows/pr-codeql-analysis-go.yml index 7dfad430af6..16f61aa9e9a 100644 --- a/.github/workflows/pr-codeql-analysis-go.yml +++ b/.github/workflows/pr-codeql-analysis-go.yml @@ -1,6 +1,7 @@ name: "CodeQL for PR / go" on: + workflow_dispatch: pull_request: branches: [main] paths: @@ -19,11 +20,21 @@ jobs: # a pull request then we can checkout the head. fetch-depth: 2 + - name: Set go version + uses: actions/setup-go@v3 + with: + go-version: '1.19.2' + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: "go" + - name: Build go files + run: | + go mod verify + make build-go + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/pr-codeql-analysis-javascript.yml b/.github/workflows/pr-codeql-analysis-javascript.yml index ac5bd159206..d8f187b309c 100644 --- a/.github/workflows/pr-codeql-analysis-javascript.yml +++ b/.github/workflows/pr-codeql-analysis-javascript.yml @@ -1,6 +1,7 @@ name: "CodeQL for PR / javascript" on: + workflow_dispatch: pull_request: branches: [main] paths: diff --git a/.github/workflows/pr-codeql-analysis-python.yml b/.github/workflows/pr-codeql-analysis-python.yml index cd4c47ce945..d6505de955f 100644 --- a/.github/workflows/pr-codeql-analysis-python.yml +++ b/.github/workflows/pr-codeql-analysis-python.yml @@ -1,6 +1,7 @@ name: "CodeQL for PR / python" on: + workflow_dispatch: pull_request: branches: [main] paths: diff --git a/go.mod b/go.mod index 0e7ea334cc2..855540e66f7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana -go 1.18 +go 1.19 // Override xorm's outdated go-mssqldb dependency, since we can't upgrade to current xorm (due to breaking changes). // We need a more current go-mssqldb so we get rid of a version of apache/thrift with vulnerabilities. From 7078871ab64cc33afa472c7b3c4c4247f8805909 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 14:45:20 +0000 Subject: [PATCH 047/926] Update dependency react-i18next to v12 (#58238) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index d84a9062db8..9615df1b13e 100644 --- a/package.json +++ b/package.json @@ -362,7 +362,7 @@ "react-grid-layout": "1.3.4", "react-highlight-words": "0.18.0", "react-hook-form": "7.5.3", - "react-i18next": "^11.18.6", + "react-i18next": "^12.0.0", "react-inlinesvg": "3.0.1", "react-moveable": "0.40.0", "react-popper": "2.3.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index a34caeb4f49..1e5b69453c6 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -90,7 +90,7 @@ "react-dropzone": "14.2.3", "react-highlight-words": "0.18.0", "react-hook-form": "7.5.3", - "react-i18next": "^11.18.6", + "react-i18next": "^12.0.0", "react-inlinesvg": "3.0.1", "react-popper": "2.3.0", "react-popper-tooltip": "^4.3.1", diff --git a/yarn.lock b/yarn.lock index 21c59d9e7d6..89e34fbfc7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4790,7 +4790,7 @@ __metadata: react-dropzone: 14.2.3 react-highlight-words: 0.18.0 react-hook-form: 7.5.3 - react-i18next: ^11.18.6 + react-i18next: ^12.0.0 react-inlinesvg: 3.0.1 react-popper: 2.3.0 react-popper-tooltip: ^4.3.1 @@ -21757,7 +21757,7 @@ __metadata: react-grid-layout: 1.3.4 react-highlight-words: 0.18.0 react-hook-form: 7.5.3 - react-i18next: ^11.18.6 + react-i18next: ^12.0.0 react-inlinesvg: 3.0.1 react-moveable: 0.40.0 react-popper: 2.3.0 @@ -32338,9 +32338,9 @@ __metadata: languageName: node linkType: hard -"react-i18next@npm:^11.18.6": - version: 11.18.6 - resolution: "react-i18next@npm:11.18.6" +"react-i18next@npm:^12.0.0": + version: 12.0.0 + resolution: "react-i18next@npm:12.0.0" dependencies: "@babel/runtime": ^7.14.5 html-parse-stringify: ^3.0.1 @@ -32352,7 +32352,7 @@ __metadata: optional: true react-native: optional: true - checksum: 624c0a0313fac4e0d18560b83c99a8bd0a83abc02e5db8d01984e0643ac409d178668aa3a4720d01f7a0d9520d38598dcbff801d6f69a970bae67461de6cd852 + checksum: f523d7ec5dcb7f5fd36efc9385639d01160071f4dadbbc5a3f1daa64b0f332f709347bdd3bc68f5aefbd72c8742233aa715b308b1ca87179ad501acc19c72068 languageName: node linkType: hard From 4758bbcb61c42bc94be33402f4792b1a3245f243 Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Fri, 4 Nov 2022 15:05:06 +0000 Subject: [PATCH 048/926] Explore: don't re-init the Graph on every data change (#57906) * Perf: remove structureRev logic from Graph in Explore * Avoid reinitializing uPlot when not needed * move fieldConfigRegistry * restore usememo for dataWithConfig --- .betterer.results | 3 - public/app/features/explore/ExploreGraph.tsx | 71 ++++++++++---------- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/.betterer.results b/.betterer.results index e12718e7ce2..1ae0fcc785d 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3897,9 +3897,6 @@ exports[`better eslint`] = { "public/app/features/explore/Explore.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/explore/ExploreGraph.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/explore/ExplorePaneContainer.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/explore/ExploreGraph.tsx b/public/app/features/explore/ExploreGraph.tsx index 08cd66aeafe..fc3613c59e8 100644 --- a/public/app/features/explore/ExploreGraph.tsx +++ b/public/app/features/explore/ExploreGraph.tsx @@ -1,13 +1,11 @@ import { css, cx } from '@emotion/css'; import { identity } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { usePrevious } from 'react-use'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useCounter } from 'react-use'; import { AbsoluteTimeRange, applyFieldOverrides, - compareArrayValues, - compareDataFrameStructures, createFieldConfigRegistry, DataFrame, dateTime, @@ -75,13 +73,13 @@ export function ExploreGraph({ eventBus, }: Props) { const theme = useTheme2(); + const style = useStyles2(getStyles); const [showAllTimeSeries, setShowAllTimeSeries] = useState(false); - const [baseStructureRev, setBaseStructureRev] = useState(1); - - const previousData = usePrevious(data); - const structureChangesRef = useRef(0); - const structureRev = baseStructureRev + structureChangesRef.current; - const prevStructureRev = usePrevious(structureRev); + const [structureRev, { inc: incrementStructureRev }] = useCounter(1); + const fieldConfigRegistry = useMemo( + () => createFieldConfigRegistry(getGraphFieldConfig(defaultGraphConfig), 'Explore'), + [] + ); const [fieldConfig, setFieldConfig] = useState({ defaults: { @@ -98,15 +96,6 @@ export function ExploreGraph({ overrides: [], }); - if (data && previousData && !compareArrayValues(previousData, data, compareDataFrameStructures)) { - structureChangesRef.current++; - - if (prevStructureRev === structureRev) { - setFieldConfig({ ...fieldConfig, overrides: [] }); - } - } - - const style = useStyles2(getStyles); const timeRange = { from: dateTime(absoluteRange.from), to: dateTime(absoluteRange.to), @@ -116,18 +105,25 @@ export function ExploreGraph({ }, }; + const styledFieldConfig = useMemo(() => applyGraphStyle(fieldConfig, graphStyle), [fieldConfig, graphStyle]); + const dataWithConfig = useMemo(() => { - const registry = createFieldConfigRegistry(getGraphFieldConfig(defaultGraphConfig), 'Explore'); - const styledFieldConfig = applyGraphStyle(fieldConfig, graphStyle); return applyFieldOverrides({ fieldConfig: styledFieldConfig, data, timeZone, replaceVariables: (value) => value, // We don't need proper replace here as it is only used in getLinks and we use getFieldLinks theme, - fieldConfigRegistry: registry, + fieldConfigRegistry, }); - }, [fieldConfig, graphStyle, data, timeZone, theme]); + }, [fieldConfigRegistry, data, timeZone, theme, styledFieldConfig]); + + // structureRev should be incremented when either the number of series or the config changes. + // like useEffect, but runs before rendering. + // TODO: while this works as it is supposed to, we are forced to do this now because of the way + // ExploreGraph is implemented. We should refactor it to a single component that handles structureRev increments + // when a user changes the viz style and not react to the value change itself. + useMemo(incrementStructureRev, [dataWithConfig.length, styledFieldConfig, incrementStructureRev]); useEffect(() => { if (onHiddenSeriesChanged) { @@ -149,11 +145,23 @@ export function ExploreGraph({ sync: () => DashboardCursorSync.Crosshair, onSplitOpen: splitOpenFn, onToggleSeriesVisibility(label: string, mode: SeriesVisibilityChangeMode) { - setBaseStructureRev((r) => r + 1); setFieldConfig(seriesVisibilityConfigFactory(label, mode, fieldConfig, data)); }, }; + const panelOptions: TimeSeriesOptions = useMemo( + () => ({ + tooltip: { mode: tooltipDisplayMode, sort: SortOrder.None }, + legend: { + displayMode: LegendDisplayMode.List, + showLegend: true, + placement: 'bottom', + calcs: [], + }, + }), + [tooltipDisplayMode] + ); + return ( {dataWithConfig.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && ( @@ -163,31 +171,20 @@ export function ExploreGraph({ { - structureChangesRef.current++; setShowAllTimeSeries(true); }} >{`Show all ${dataWithConfig.length}`}
)} ); From 4749f45fe8f77a38866bb3d85cbed1024861c886 Mon Sep 17 00:00:00 2001 From: Gareth Dawson Date: Fri, 4 Nov 2022 15:18:55 +0000 Subject: [PATCH 049/926] Loki: Replace hardcoded css values (#57770) * replace hardcoded margin/padding in getLogRowStyles.ts * replace hardcoded margin/padding in LogDetails.tsx * replace hardcoded values in LogDetailsRow.tsx * replace hardcoded values in LogLabels.tsx * replace hardcoded values in LogLabelStats.tsx * replace hardcoded values in LogLabelStatsRow.tsx * replace hardcoded values in LogRowContext.tsx * replace hardcoded values in LogRowMessage.tsx * replace hardcoded values * remove forced theme spacing values --- .../features/logs/components/LogDetails.tsx | 2 +- .../features/logs/components/LogDetailsRow.tsx | 7 ++++--- .../features/logs/components/LogLabelStats.tsx | 2 +- .../logs/components/LogLabelStatsRow.tsx | 10 +++++----- .../app/features/logs/components/LogLabels.tsx | 6 +++--- .../features/logs/components/LogRowContext.tsx | 18 +++++++++--------- .../features/logs/components/LogRowMessage.tsx | 6 +++--- .../logs/components/getLogRowStyles.ts | 12 ++++++------ 8 files changed, 32 insertions(+), 31 deletions(-) diff --git a/public/app/features/logs/components/LogDetails.tsx b/public/app/features/logs/components/LogDetails.tsx index c030bf9f8d0..c0c423efbaa 100644 --- a/public/app/features/logs/components/LogDetails.tsx +++ b/public/app/features/logs/components/LogDetails.tsx @@ -126,7 +126,7 @@ class UnThemedLogDetails extends PureComponent { name="question-circle" size="xs" className={css` - margin-left: 4px; + margin-left: ${theme.spacing(0.5)}; `} /> diff --git a/public/app/features/logs/components/LogDetailsRow.tsx b/public/app/features/logs/components/LogDetailsRow.tsx index 6b7d85ecb9d..763fd652fed 100644 --- a/public/app/features/logs/components/LogDetailsRow.tsx +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -57,9 +57,9 @@ const getStyles = (theme: GrafanaTheme2) => { position: absolute; top: 0px; justify-content: center; - border-radius: 20px; - width: 26px; - height: 26px; + border-radius: ${theme.shape.borderRadius(10)}; + width: ${theme.spacing(3.25)}; + height: ${theme.spacing(3.25)}; `, wrapLine: css` label: wrapLine; @@ -67,6 +67,7 @@ const getStyles = (theme: GrafanaTheme2) => { `, }; }; + class UnThemedLogDetailsRow extends PureComponent { state: State = { showFieldsStats: false, diff --git a/public/app/features/logs/components/LogLabelStats.tsx b/public/app/features/logs/components/LogLabelStats.tsx index 65c71415833..f2c5d1ba160 100644 --- a/public/app/features/logs/components/LogLabelStats.tsx +++ b/public/app/features/logs/components/LogLabelStats.tsx @@ -39,7 +39,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { `, logsStatsBody: css` label: logs-stats__body; - padding: 5px 0; + padding: 5px 0px; `, }; }); diff --git a/public/app/features/logs/components/LogLabelStatsRow.tsx b/public/app/features/logs/components/LogLabelStatsRow.tsx index 2681a4bb187..2960c807658 100644 --- a/public/app/features/logs/components/LogLabelStatsRow.tsx +++ b/public/app/features/logs/components/LogLabelStatsRow.tsx @@ -28,23 +28,23 @@ const getStyles = (theme: GrafanaTheme2) => ({ logsStatsRowCount: css` label: logs-stats-row__count; text-align: right; - margin-left: 0.5em; + margin-left: ${theme.spacing(0.75)}; `, logsStatsRowPercent: css` label: logs-stats-row__percent; text-align: right; - margin-left: 0.5em; - width: 3em; + margin-left: ${theme.spacing(0.75)}; + width: ${theme.spacing(4.5)}; `, logsStatsRowBar: css` label: logs-stats-row__bar; - height: 4px; + height: ${theme.spacing(0.5)}; overflow: hidden; background: ${theme.colors.text.disabled}; `, logsStatsRowInnerBar: css` label: logs-stats-row__innerbar; - height: 4px; + height: ${theme.spacing(0.5)}; overflow: hidden; background: ${theme.colors.primary.main}; `, diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index b499b19f02d..a8eaf446b5b 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -53,10 +53,10 @@ const getStyles = (theme: GrafanaTheme2) => { logsLabel: css` label: logs-label; display: flex; - padding: 0 2px; + padding: ${theme.spacing(0, 0.25)}; background-color: ${theme.colors.background.secondary}; border-radius: ${theme.shape.borderRadius(1)}; - margin: 1px 4px 0 0; + margin: ${theme.spacing(0.125, 0.5, 0, 0)}; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -64,7 +64,7 @@ const getStyles = (theme: GrafanaTheme2) => { logsLabelValue: css` label: logs-label__value; display: inline-block; - max-width: 20em; + max-width: ${theme.spacing(25)}; text-overflow: ellipsis; overflow: hidden; `, diff --git a/public/app/features/logs/components/LogRowContext.tsx b/public/app/features/logs/components/LogRowContext.tsx index 6f96c756dc2..346082b35c5 100644 --- a/public/app/features/logs/components/LogRowContext.tsx +++ b/public/app/features/logs/components/LogRowContext.tsx @@ -49,7 +49,7 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) top: 100%; ` : css` - margin-top: 20px; + margin-top: ${theme.spacing(2.5)}; `; return { width: css` @@ -61,22 +61,22 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) z-index: ${theme.zIndex.dropdown}; overflow: hidden; background: ${theme.colors.background.primary}; - box-shadow: 0 0 10px ${theme.v1.palette.black}; + box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; border: 1px solid ${theme.colors.background.secondary}; border-radius: ${theme.shape.borderRadius(2)}; font-family: ${theme.typography.fontFamily}; `, header: css` height: ${headerHeight}px; - padding: 0 10px; + padding: ${theme.spacing(0, 1.25)}; display: flex; align-items: center; background: ${theme.colors.background.canvas}; `, top: css` border-radius: 0 0 ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)}; - box-shadow: 0 0 10px ${theme.v1.palette.black}; - clip-path: inset(0px -10px -10px -10px); + box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; + clip-path: inset(0px -${theme.spacing(1.25)} -${theme.spacing(1.25)} -${theme.spacing(1.25)}); `, title: css` position: absolute; @@ -87,8 +87,8 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) background: ${theme.colors.background.secondary}; border: 1px solid ${theme.colors.background.secondary}; border-radius: ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)} 0 0; - box-shadow: 0 0 10px ${theme.v1.palette.black}; - clip-path: inset(-10px -10px 0px -10px); + box-shadow: 0 0 ${theme.spacing(1.25)} ${theme.v1.palette.black}; + clip-path: inset(-${theme.spacing(1.25)} -${theme.spacing(1.25)} 0px -${theme.spacing(1.25)}); font-family: ${theme.typography.fontFamily}; display: flex; @@ -107,11 +107,11 @@ const getLogRowContextStyles = (theme: GrafanaTheme2, wrapLogMessage?: boolean) display: flex; `, headerButton: css` - margin-left: 8px; + margin-left: ${theme.spacing(1)}; `, logs: css` height: ${logsHeight}px; - padding: 10px; + padding: ${theme.spacing(1.25)}; font-family: ${theme.typography.fontFamilyMonospace}; .scrollbar-view { diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index cad836d24c0..8a2efcc694c 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -64,18 +64,18 @@ const getStyles = (theme: GrafanaTheme2, showContextButton: boolean, isInDashboa position: absolute; top: 0; bottom: auto; - height: 36px; + height: ${theme.spacing(4.5)}; background: ${theme.colors.background.primary}; box-shadow: ${theme.shadows.z3}; padding: ${theme.spacing(0, 0, 0, 0.5)}; z-index: 100; visibility: hidden; - width: ${showContextButton ? '80px' : '40px'}; + width: ${showContextButton ? theme.spacing(10) : theme.spacing(5)}; `, logRowMenuCell: css` position: absolute; right: ${isInDashboard ? '40px' : `calc(75px + ${theme.spacing()} + ${showContextButton ? '80px' : '40px'})`}; - margin-top: -1px; + margin-top: -${theme.spacing(0.125)}; `, }; }; diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index 1ef851e803d..14f8b6017d4 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -83,7 +83,7 @@ export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => { `, logsRowLevel: css` label: logs-row__level; - max-width: 10px; + max-width: ${theme.spacing(1.25)}; cursor: default; &::after { content: ''; @@ -92,7 +92,7 @@ export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => { top: 1px; bottom: 1px; width: 3px; - left: 4px; + left: ${theme.spacing(0.5)}; background-color: ${logColor}; } `, @@ -130,8 +130,8 @@ export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => { label: logs-row-details-table; border: 1px solid ${theme.colors.border.medium}; padding: 0 ${theme.spacing(1)} ${theme.spacing(1)}; - border-radius: 3px; - margin: 20px 8px 20px 16px; + border-radius: ${theme.shape.borderRadius(1.5)}; + margin: ${theme.spacing(2.5)} ${theme.spacing(1)} ${theme.spacing(2.5)} ${theme.spacing(2)}; cursor: default; `, logDetailsTable: css` @@ -146,8 +146,8 @@ export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => { label: logs-row-details__icon; position: relative; color: ${theme.v1.palette.gray3}; - padding-top: 6px; - padding-left: 6px; + padding-top: ${theme.spacing(0.75)}; + padding-left: ${theme.spacing(0.75)}; `, logDetailsLabel: css` label: logs-row-details__label; From c1ea944c798b17b0a474a90087f498feb7d8a588 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 15:28:47 +0000 Subject: [PATCH 050/926] Update dependency @types/node to v18 (#58139) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-e2e/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 18 +++++++++--------- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 9615df1b13e..236064b5abc 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "@types/lodash": "4.14.187", "@types/logfmt": "^1.2.1", "@types/mousetrap": "1.6.10", - "@types/node": "16.11.45", + "@types/node": "18.11.9", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.0.6", "@types/papaparse": "5.3.5", "@types/pluralize": "^0.0.29", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 424d71cb8bf..8656394717e 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -70,7 +70,7 @@ "@types/jquery": "3.5.14", "@types/lodash": "4.14.187", "@types/marked": "4.0.7", - "@types/node": "16.11.45", + "@types/node": "18.11.9", "@types/papaparse": "5.3.5", "@types/react": "17.0.42", "@types/react-dom": "17.0.14", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 46a5d3f4be1..cb674092a0c 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@rollup/plugin-commonjs": "23.0.2", "@rollup/plugin-node-resolve": "15.0.1", - "@types/node": "16.11.45", + "@types/node": "18.11.9", "esbuild": "0.15.12", "rimraf": "3.0.2", "rollup": "2.79.1", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index b0dffe2fb8b..4bb67d329f2 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -48,7 +48,7 @@ "@rollup/plugin-node-resolve": "15.0.1", "@types/chrome-remote-interface": "0.31.4", "@types/lodash": "4.14.187", - "@types/node": "16.11.45", + "@types/node": "18.11.9", "@types/uuid": "8.3.4", "esbuild": "0.15.12", "rollup": "2.79.1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 1e5b69453c6..e3462c41bd9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -149,7 +149,7 @@ "@types/jquery": "3.5.14", "@types/lodash": "4.14.187", "@types/mock-raf": "1.0.3", - "@types/node": "16.11.45", + "@types/node": "18.11.9", "@types/prismjs": "1.26.0", "@types/react": "17.0.42", "@types/react-beautiful-dnd": "13.1.2", diff --git a/yarn.lock b/yarn.lock index 89e34fbfc7f..19ff6dc2fd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4343,7 +4343,7 @@ __metadata: "@types/jquery": 3.5.14 "@types/lodash": 4.14.187 "@types/marked": 4.0.7 - "@types/node": 16.11.45 + "@types/node": 18.11.9 "@types/papaparse": 5.3.5 "@types/react": 17.0.42 "@types/react-dom": 17.0.14 @@ -4392,7 +4392,7 @@ __metadata: "@grafana/tsconfig": ^1.2.0-rc1 "@rollup/plugin-commonjs": 23.0.2 "@rollup/plugin-node-resolve": 15.0.1 - "@types/node": 16.11.45 + "@types/node": 18.11.9 esbuild: 0.15.12 rimraf: 3.0.2 rollup: 2.79.1 @@ -4417,7 +4417,7 @@ __metadata: "@rollup/plugin-node-resolve": 15.0.1 "@types/chrome-remote-interface": 0.31.4 "@types/lodash": 4.14.187 - "@types/node": 16.11.45 + "@types/node": 18.11.9 "@types/uuid": 8.3.4 babel-loader: 9.1.0 blink-diff: 1.0.13 @@ -4730,7 +4730,7 @@ __metadata: "@types/jquery": 3.5.14 "@types/lodash": 4.14.187 "@types/mock-raf": 1.0.3 - "@types/node": 16.11.45 + "@types/node": 18.11.9 "@types/prismjs": 1.26.0 "@types/react": 17.0.42 "@types/react-beautiful-dnd": 13.1.2 @@ -11221,10 +11221,10 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:16.11.45": - version: 16.11.45 - resolution: "@types/node@npm:16.11.45" - checksum: 57d61c951024f66d796e71e4a972faef266007398cd4e93a195822fea2d5deb41d0615f394a99ece89772b145ff057321d138c7e3442455dc7d785ff67cebde3 +"@types/node@npm:18.11.9": + version: 18.11.9 + resolution: "@types/node@npm:18.11.9" + checksum: cc0aae109e9b7adefc32eecb838d6fad931663bb06484b5e9cbbbf74865c721b03d16fd8d74ad90e31dbe093d956a7c2c306ba5429ba0c00f3f7505103d7a496 languageName: node linkType: hard @@ -21596,7 +21596,7 @@ __metadata: "@types/lodash": 4.14.187 "@types/logfmt": ^1.2.1 "@types/mousetrap": 1.6.10 - "@types/node": 16.11.45 + "@types/node": 18.11.9 "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.0.6" "@types/papaparse": 5.3.5 "@types/pluralize": ^0.0.29 From cc8c1380e22cd6f5840deada541d4aa269eee0fa Mon Sep 17 00:00:00 2001 From: Alexander Weaver Date: Fri, 4 Nov 2022 10:39:26 -0500 Subject: [PATCH 051/926] Alerting: Persist annotations from multidimensional rules in batches (#56575) * Reduce piecemeal state fields * Read data directly off state instead of rule * Unify state and context into single struct * Expose contextual information to layer above setNextState * Work in terms of ContextualState and call historian in batches * Call annotations service in batches * Export format state and reason and remove workaround in unrelated test package * Add new method to annotation service for batch inserting * Fix loop variable aliasing bug caught by linter, didn't change behavior * Incl timerange on annotation tests * Insert one at a time if tags are present * Point to rule from ContextualState rather than copy fields * Build annotations and copy data prior to starting goroutine * Rename to StateTransition * Use new bulk-insert utility * Remove rule from StateTransition and pass in directly to historian * Simplify annotations logic since we have only one rule * Fix logs and context, nilcheck, simplify method name * Regenerate mock --- pkg/services/annotations/annotations.go | 1 + .../annotations_repository_mock.go | 16 +++- .../annotationsimpl/annotations.go | 6 ++ .../annotations/annotationsimpl/store.go | 3 +- .../annotations/annotationsimpl/xorm_store.go | 58 +++++++++++- .../annotationsimpl/xorm_store_test.go | 42 +++++++++ .../annotations/annotationstest/fake.go | 15 +++ pkg/services/annotations/models.go | 2 +- pkg/services/ngalert/api/api_prometheus.go | 12 +-- .../ngalert/state/historian/annotation.go | 91 +++++++++++++------ pkg/services/ngalert/state/manager.go | 89 ++++++++++-------- pkg/services/ngalert/state/persist.go | 4 +- pkg/services/ngalert/state/state.go | 27 ++++++ pkg/services/ngalert/state/testing.go | 3 +- 14 files changed, 284 insertions(+), 85 deletions(-) diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index 446e3a68105..925bebb16cb 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -16,6 +16,7 @@ var ( //go:generate mockery --name Repository --structname FakeAnnotationsRepo --inpackage --filename annotations_repository_mock.go type Repository interface { Save(ctx context.Context, item *Item) error + SaveMany(ctx context.Context, items []Item) error Update(ctx context.Context, item *Item) error Find(ctx context.Context, query *ItemQuery) ([]*ItemDTO, error) Delete(ctx context.Context, params *DeleteParams) error diff --git a/pkg/services/annotations/annotations_repository_mock.go b/pkg/services/annotations/annotations_repository_mock.go index d81766764e6..242834ced2b 100644 --- a/pkg/services/annotations/annotations_repository_mock.go +++ b/pkg/services/annotations/annotations_repository_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.12.1. DO NOT EDIT. +// Code generated by mockery v2.12.0. DO NOT EDIT. package annotations @@ -86,6 +86,20 @@ func (_m *FakeAnnotationsRepo) Save(ctx context.Context, item *Item) error { return r0 } +// SaveMany provides a mock function with given fields: ctx, items +func (_m *FakeAnnotationsRepo) SaveMany(ctx context.Context, items []Item) error { + ret := _m.Called(ctx, items) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []Item) error); ok { + r0 = rf(ctx, items) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, item func (_m *FakeAnnotationsRepo) Update(ctx context.Context, item *Item) error { ret := _m.Called(ctx, item) diff --git a/pkg/services/annotations/annotationsimpl/annotations.go b/pkg/services/annotations/annotationsimpl/annotations.go index 734398798f7..fd1bfa4e8f6 100644 --- a/pkg/services/annotations/annotationsimpl/annotations.go +++ b/pkg/services/annotations/annotationsimpl/annotations.go @@ -30,6 +30,12 @@ func (r *RepositoryImpl) Save(ctx context.Context, item *annotations.Item) error return r.store.Add(ctx, item) } +// SaveMany inserts multiple annotations at once. +// It does not return IDs associated with created annotations. If you need this functionality, use the single-item Save instead. +func (r *RepositoryImpl) SaveMany(ctx context.Context, items []annotations.Item) error { + return r.store.AddMany(ctx, items) +} + func (r *RepositoryImpl) Update(ctx context.Context, item *annotations.Item) error { return r.store.Update(ctx, item) } diff --git a/pkg/services/annotations/annotationsimpl/store.go b/pkg/services/annotations/annotationsimpl/store.go index d19cec5f8cb..935afa85bd9 100644 --- a/pkg/services/annotations/annotationsimpl/store.go +++ b/pkg/services/annotations/annotationsimpl/store.go @@ -8,7 +8,8 @@ import ( ) type store interface { - Add(ctx context.Context, item *annotations.Item) error + Add(ctx context.Context, items *annotations.Item) error + AddMany(ctx context.Context, items []annotations.Item) error Update(ctx context.Context, item *annotations.Item) error Get(ctx context.Context, query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) Delete(ctx context.Context, params *annotations.DeleteParams) error diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index 3b6439f8c7f..661d16f1ba8 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/tag" @@ -63,9 +64,64 @@ func (r *xormRepositoryImpl) Add(ctx context.Context, item *annotations.Item) er if _, err := sess.Table("annotation").Insert(item); err != nil { return err } + return r.synchronizeTags(ctx, item) + }) +} +// AddMany inserts large batches of annotations at once. +// It does not return IDs associated with created annotations, and it does not support annotations with tags. If you need this functionality, use the single-item Add instead. +// This is due to a limitation with some supported databases: +// We cannot correlate the IDs of batch-inserted records without acquiring a full table lock in MySQL. +// Annotations have no other uniquifier field, so we also cannot re-query for them after the fact. +// So, callers can only reliably use this endpoint if they don't care about returned IDs. +func (r *xormRepositoryImpl) AddMany(ctx context.Context, items []annotations.Item) error { + hasTags := make([]annotations.Item, 0) + hasNoTags := make([]annotations.Item, 0) + + for i, item := range items { + tags := tag.ParseTagPairs(item.Tags) + item.Tags = tag.JoinTagPairs(tags) + item.Created = timeNow().UnixNano() / int64(time.Millisecond) + item.Updated = item.Created + if item.Epoch == 0 { + item.Epoch = item.Created + } + if err := r.validateItem(&items[i]); err != nil { + return err + } + + if len(item.Tags) > 0 { + hasTags = append(hasTags, item) + } else { + hasNoTags = append(hasNoTags, item) + } + } + + return r.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + // We can batch-insert every annotation with no tags. If an annotation has tags, we need the ID. + opts := sqlstore.NativeSettingsForDialect(r.db.GetDialect()) + if _, err := sess.BulkInsert("annotation", hasNoTags, opts); err != nil { + return err + } + + for i, item := range hasTags { + if _, err := sess.Table("annotation").Insert(item); err != nil { + return err + } + if err := r.synchronizeTags(ctx, &hasTags[i]); err != nil { + return err + } + } + + return nil + }) +} + +func (r *xormRepositoryImpl) synchronizeTags(ctx context.Context, item *annotations.Item) error { + // Will re-use session if one has already been opened with the same ctx. + return r.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { if item.Tags != nil { - tags, err := r.tagService.EnsureTagsExist(ctx, tags) + tags, err := r.tagService.EnsureTagsExist(ctx, tag.ParseTagPairs(item.Tags)) if err != nil { return err } diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 1b9a9a8073c..e7615243fdc 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -163,6 +163,47 @@ func TestIntegrationAnnotations(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, annotations.ErrBaseTagLimitExceeded) + t.Run("Can batch-insert annotations", func(t *testing.T) { + count := 10 + items := make([]annotations.Item, count) + for i := 0; i < count; i++ { + items[i] = annotations.Item{ + OrgId: 100, + Type: "batch", + Epoch: 12, + } + } + + err := repo.AddMany(context.Background(), items) + + require.NoError(t, err) + query := &annotations.ItemQuery{OrgId: 100, SignedInUser: testUser} + inserted, err := repo.Get(context.Background(), query) + require.NoError(t, err) + assert.Len(t, inserted, count) + }) + + t.Run("Can batch-insert annotations with tags", func(t *testing.T) { + count := 10 + items := make([]annotations.Item, count) + for i := 0; i < count; i++ { + items[i] = annotations.Item{ + OrgId: 101, + Type: "batch", + Epoch: 12, + } + } + items[0].Tags = []string{"type:test"} + + err := repo.AddMany(context.Background(), items) + + require.NoError(t, err) + query := &annotations.ItemQuery{OrgId: 101, SignedInUser: testUser} + inserted, err := repo.Get(context.Background(), query) + require.NoError(t, err) + assert.Len(t, inserted, count) + }) + t.Run("Can query for annotation by id", func(t *testing.T) { items, err := repo.Get(context.Background(), &annotations.ItemQuery{ OrgId: 1, @@ -448,6 +489,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { OrgId: 1, DashboardId: 2, Epoch: 10, + Tags: []string{"foo:bar"}, } err = repo.Add(context.Background(), dash2Annotation) require.NoError(t, err) diff --git a/pkg/services/annotations/annotationstest/fake.go b/pkg/services/annotations/annotationstest/fake.go index 1c9b367c891..06cd76263dc 100644 --- a/pkg/services/annotations/annotationstest/fake.go +++ b/pkg/services/annotations/annotationstest/fake.go @@ -43,6 +43,21 @@ func (repo *fakeAnnotationsRepo) Save(ctx context.Context, item *annotations.Ite item.Id = int64(len(repo.annotations) + 1) } repo.annotations[item.Id] = *item + + return nil +} + +func (repo *fakeAnnotationsRepo) SaveMany(ctx context.Context, items []annotations.Item) error { + repo.mtx.Lock() + defer repo.mtx.Unlock() + + for _, i := range items { + if i.Id == 0 { + i.Id = int64(len(repo.annotations) + 1) + } + repo.annotations[i.Id] = i + } + return nil } diff --git a/pkg/services/annotations/models.go b/pkg/services/annotations/models.go index 6f393342790..c6d97fdf8a0 100644 --- a/pkg/services/annotations/models.go +++ b/pkg/services/annotations/models.go @@ -62,7 +62,7 @@ type DeleteParams struct { } type Item struct { - Id int64 `json:"id"` + Id int64 `json:"id" xorm:"pk autoincr 'id'"` OrgId int64 `json:"orgId"` UserId int64 `json:"userId"` DashboardId int64 `json:"dashboardId"` diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index 49af1a46e1b..d263a340eda 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -60,11 +60,7 @@ func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Re // TODO: or should we make this two fields? Using one field lets the // frontend use the same logic for parsing text on annotations and this. - State: state.InstanceStateAndReason{ - State: alertState.State, - Reason: alertState.StateReason, - }.String(), - + State: state.FormatStateAndReason(alertState.State, alertState.StateReason), ActiveAt: &startsAt, Value: valString, }) @@ -221,11 +217,7 @@ func (srv PrometheusSrv) toRuleGroup(groupName string, folder *models.Folder, ru // TODO: or should we make this two fields? Using one field lets the // frontend use the same logic for parsing text on annotations and this. - State: state.InstanceStateAndReason{ - State: alertState.State, - Reason: alertState.StateReason, - }.String(), - + State: state.FormatStateAndReason(alertState.State, alertState.StateReason), ActiveAt: &activeAt, Value: valString, } diff --git a/pkg/services/ngalert/state/historian/annotation.go b/pkg/services/ngalert/state/historian/annotation.go index c9f56338700..c94805178af 100644 --- a/pkg/services/ngalert/state/historian/annotation.go +++ b/pkg/services/ngalert/state/historian/annotation.go @@ -33,45 +33,82 @@ func NewAnnotationHistorian(annotations annotations.Repository, dashboards dashb } } -func (h *AnnotationStateHistorian) RecordState(ctx context.Context, rule *ngmodels.AlertRule, currentState *state.State, evaluatedAt time.Time, currentData, previousData state.InstanceStateAndReason) { - logger := h.log.New(rule.GetKey().LogContext()...) - logger.Debug("Alert state changed creating annotation", "newState", currentData.String(), "oldState", previousData.String()) +// RecordStates writes a number of state transitions for a given rule to state history. +func (h *AnnotationStateHistorian) RecordStates(ctx context.Context, rule *ngmodels.AlertRule, states []state.StateTransition) { + logger := h.log.FromContext(ctx) + // Build annotations before starting goroutine, to make sure all data is copied and won't mutate underneath us. + annotations := h.buildAnnotations(rule, states, logger) + panel := parsePanelKey(rule, logger) + go h.recordAnnotationsSync(ctx, panel, annotations, logger) +} - annotationText, annotationData := buildAnnotationTextAndData(rule, currentState) - item := &annotations.Item{ - AlertId: rule.ID, - OrgId: rule.OrgID, - PrevState: previousData.String(), - NewState: currentData.String(), - Text: annotationText, - Data: annotationData, - Epoch: evaluatedAt.UnixNano() / int64(time.Millisecond), +func (h *AnnotationStateHistorian) buildAnnotations(rule *ngmodels.AlertRule, states []state.StateTransition, logger log.Logger) []annotations.Item { + items := make([]annotations.Item, 0, len(states)) + for _, state := range states { + logger.Debug("Alert state changed creating annotation", "newState", state.Formatted(), "oldState", state.PreviousFormatted()) + + annotationText, annotationData := buildAnnotationTextAndData(rule, state.State) + + item := annotations.Item{ + AlertId: rule.ID, + OrgId: state.OrgID, + PrevState: state.PreviousFormatted(), + NewState: state.Formatted(), + Text: annotationText, + Data: annotationData, + Epoch: state.LastEvaluationTime.UnixNano() / int64(time.Millisecond), + } + + items = append(items, item) } + return items +} - dashUid, ok := rule.Annotations[ngmodels.DashboardUIDAnnotation] +// panelKey uniquely identifies a panel. +type panelKey struct { + orgID int64 + dashUID string + panelID int64 +} + +// panelKey attempts to get the key of the panel attached to the given rule. Returns nil if the rule is not attached to a panel. +func parsePanelKey(rule *ngmodels.AlertRule, logger log.Logger) *panelKey { + dashUID, ok := rule.Annotations[ngmodels.DashboardUIDAnnotation] if ok { - panelUid := rule.Annotations[ngmodels.PanelIDAnnotation] - - panelId, err := strconv.ParseInt(panelUid, 10, 64) + panelAnno := rule.Annotations[ngmodels.PanelIDAnnotation] + panelID, err := strconv.ParseInt(panelAnno, 10, 64) if err != nil { - logger.Error("Error parsing panelUID for alert annotation", "panelUID", panelUid, "error", err) + logger.Error("Error parsing panelUID for alert annotation", "actual", panelAnno, "error", err) + return nil + } + return &panelKey{ + orgID: rule.OrgID, + dashUID: dashUID, + panelID: panelID, + } + } + return nil +} + +func (h *AnnotationStateHistorian) recordAnnotationsSync(ctx context.Context, panel *panelKey, annotations []annotations.Item, logger log.Logger) { + if panel != nil { + dashID, err := h.dashboards.getID(ctx, panel.orgID, panel.dashUID) + if err != nil { + logger.Error("Error getting dashboard for alert annotation", "dashboardUID", panel.dashUID, "error", err) return } - dashID, err := h.dashboards.getID(ctx, rule.OrgID, dashUid) - if err != nil { - logger.Error("Error getting dashboard for alert annotation", "dashboardUID", dashUid, "error", err) - return + for _, i := range annotations { + i.DashboardId = dashID + i.PanelId = panel.panelID } - - item.PanelId = panelId - item.DashboardId = dashID } - if err := h.annotations.Save(ctx, item); err != nil { - logger.Error("Error saving alert annotation", "error", err) - return + if err := h.annotations.SaveMany(ctx, annotations); err != nil { + logger.Error("Error saving alert annotation batch", "error", err) } + + logger.Debug("Done saving alert annotation batch") } func buildAnnotationTextAndData(rule *ngmodels.AlertRule, currentState *state.State) (string, *simplejson.Json) { diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 900049819d4..89d878a0e3a 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -2,7 +2,6 @@ package state import ( "context" - "fmt" "net/url" "time" @@ -169,23 +168,40 @@ func (st *Manager) ResetStateByRuleUID(ctx context.Context, ruleKey ngModels.Ale func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, results eval.Results, extraLabels data.Labels) []*State { logger := st.log.FromContext(ctx) logger.Debug("State manager processing evaluation results", "resultCount", len(results)) - var states []*State + var states []StateTransition processedResults := make(map[string]*State, len(results)) for _, result := range results { s := st.setNextState(ctx, alertRule, result, extraLabels, logger) states = append(states, s) - processedResults[s.CacheID] = s + processedResults[s.State.CacheID] = s.State } resolvedStates := st.staleResultsHandler(ctx, evaluatedAt, alertRule, processedResults, logger) if len(states) > 0 && st.instanceStore != nil { logger.Debug("Saving new states to the database", "count", len(states)) _, _ = st.saveAlertStates(ctx, states...) } - return append(states, resolvedStates...) + + changedStates := make([]StateTransition, 0, len(states)) + for _, s := range states { + if s.changed() { + changedStates = append(changedStates, s) + } + } + + if st.historian != nil { + st.historian.RecordStates(ctx, alertRule, changedStates) + } + + deltas := append(states, resolvedStates...) + nextStates := make([]*State, 0, len(states)) + for _, s := range deltas { + nextStates = append(nextStates, s.State) + } + return nextStates } // Set the current state based on evaluation results -func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRule, result eval.Result, extraLabels data.Labels, logger log.Logger) *State { +func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRule, result eval.Result, extraLabels data.Labels, logger log.Logger) StateTransition { currentState := st.cache.getOrCreate(ctx, st.log, alertRule, result, extraLabels, st.externalURL) currentState.LastEvaluationTime = result.EvaluatedAt @@ -241,13 +257,13 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu st.cache.set(currentState) - shouldUpdateAnnotation := oldState != currentState.State || oldReason != currentState.StateReason - if shouldUpdateAnnotation && st.historian != nil { - go st.historian.RecordState(ctx, alertRule, currentState, result.EvaluatedAt, - InstanceStateAndReason{State: currentState.State, Reason: currentState.StateReason}, - InstanceStateAndReason{State: oldState, Reason: oldReason}) + nextState := StateTransition{ + State: currentState, + PreviousState: oldState, + PreviousStateReason: oldReason, } - return currentState + + return nextState } func (st *Manager) GetAll(orgID int64) []*State { @@ -283,12 +299,13 @@ func (st *Manager) Put(states []*State) { } // TODO: Is the `State` type necessary? Should it embed the instance? -func (st *Manager) saveAlertStates(ctx context.Context, states ...*State) (saved, failed int) { +func (st *Manager) saveAlertStates(ctx context.Context, states ...StateTransition) (saved, failed int) { + logger := st.log.FromContext(ctx) if st.instanceStore == nil { return 0, 0 } - st.log.Debug("Saving alert states", "count", len(states)) + logger.Debug("Saving alert states", "count", len(states)) instances := make([]ngModels.AlertInstance, 0, len(states)) type debugInfo struct { @@ -303,8 +320,8 @@ func (st *Manager) saveAlertStates(ctx context.Context, states ...*State) (saved labels := ngModels.InstanceLabels(s.Labels) _, hash, err := labels.StringAndHash() if err != nil { - debug = append(debug, debugInfo{s.OrgID, s.AlertRuleUID, s.State.String(), s.Labels.String()}) - st.log.Error("Failed to save alert instance with invalid labels", "orgID", s.OrgID, "rule", s.AlertRuleUID, "error", err) + debug = append(debug, debugInfo{s.OrgID, s.AlertRuleUID, s.State.State.String(), s.Labels.String()}) + logger.Error("Failed to save alert instance with invalid labels", "error", err) continue } fields := ngModels.AlertInstance{ @@ -314,7 +331,7 @@ func (st *Manager) saveAlertStates(ctx context.Context, states ...*State) (saved LabelsHash: hash, }, Labels: ngModels.InstanceLabels(s.Labels), - CurrentState: ngModels.InstanceStateType(s.State.String()), + CurrentState: ngModels.InstanceStateType(s.State.State.String()), CurrentReason: s.StateReason, LastEvalTime: s.LastEvaluationTime, CurrentStateSince: s.StartsAt, @@ -327,7 +344,7 @@ func (st *Manager) saveAlertStates(ctx context.Context, states ...*State) (saved for _, inst := range instances { debug = append(debug, debugInfo{inst.RuleOrgID, inst.RuleUID, string(inst.CurrentState), data.Labels(inst.Labels).String()}) } - st.log.Error("Failed to save alert states", "states", debug, "error", err) + logger.Error("Failed to save alert states", "states", debug, "error", err) return 0, len(debug) } @@ -346,26 +363,12 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { } } -// This struct provides grouping of state with reason, and string formatting. -type InstanceStateAndReason struct { - State eval.State - Reason string -} - -func (i InstanceStateAndReason) String() string { - s := fmt.Sprintf("%v", i.State) - if len(i.Reason) > 0 { - s += fmt.Sprintf(" (%v)", i.Reason) - } - return s -} - -func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, states map[string]*State, logger log.Logger) []*State { +func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, states map[string]*State, logger log.Logger) []StateTransition { // If we are removing two or more stale series it makes sense to share the resolved image as the alert rule is the same. // TODO: We will need to change this when we support images without screenshots as each series will have a different image var resolvedImage *ngModels.Image - var resolvedStates []*State + var resolvedStates []StateTransition allStates := st.GetStatesForRuleUID(alertRule.OrgID, alertRule.UID) toDelete := make([]ngModels.AlertInstanceKey, 0) @@ -383,16 +386,18 @@ func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Tim toDelete = append(toDelete, ngModels.AlertInstanceKey{RuleOrgID: s.OrgID, RuleUID: s.AlertRuleUID, LabelsHash: labelsHash}) if s.State == eval.Alerting { - previousState := InstanceStateAndReason{State: s.State, Reason: s.StateReason} + oldState := s.State + oldReason := s.StateReason + s.State = eval.Normal s.StateReason = ngModels.StateReasonMissingSeries s.EndsAt = evaluatedAt s.Resolved = true - if st.historian != nil { - st.historian.RecordState(ctx, alertRule, s, evaluatedAt, - InstanceStateAndReason{State: eval.Normal, Reason: s.StateReason}, - previousState, - ) + s.LastEvaluationTime = evaluatedAt + record := StateTransition{ + State: s, + PreviousState: oldState, + PreviousStateReason: oldReason, } // If there is no resolved image for this rule then take one @@ -408,11 +413,15 @@ func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Tim } } s.Image = resolvedImage - resolvedStates = append(resolvedStates, s) + resolvedStates = append(resolvedStates, record) } } } + if st.historian != nil { + st.historian.RecordStates(ctx, alertRule, resolvedStates) + } + if st.instanceStore != nil { if err := st.instanceStore.DeleteAlertInstances(ctx, toDelete...); err != nil { logger.Error("Unable to delete stale instances from database", "error", err, "count", len(toDelete)) diff --git a/pkg/services/ngalert/state/persist.go b/pkg/services/ngalert/state/persist.go index db132d3b96b..7dacf955b93 100644 --- a/pkg/services/ngalert/state/persist.go +++ b/pkg/services/ngalert/state/persist.go @@ -2,7 +2,6 @@ package state import ( "context" - "time" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -23,5 +22,6 @@ type RuleReader interface { // Historian maintains an audit log of alert state history. type Historian interface { - RecordState(ctx context.Context, rule *models.AlertRule, currentState *State, evaluatedAt time.Time, currentData, previousData InstanceStateAndReason) + // RecordStates writes a number of state transitions for a given rule to state history. + RecordStates(ctx context.Context, rule *models.AlertRule, states []StateTransition) } diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index 4aae855b89f..bfd4237f27e 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -73,6 +73,25 @@ func (a *State) GetRuleKey() models.AlertRuleKey { } } +// StateTransition describes the transition from one state to another. +type StateTransition struct { + *State + PreviousState eval.State + PreviousStateReason string +} + +func (c StateTransition) Formatted() string { + return FormatStateAndReason(c.State.State, c.State.StateReason) +} + +func (c StateTransition) PreviousFormatted() string { + return FormatStateAndReason(c.PreviousState, c.PreviousStateReason) +} + +func (c StateTransition) changed() bool { + return c.PreviousState != c.State.State || c.PreviousStateReason != c.State.StateReason +} + type Evaluation struct { EvaluationTime time.Time EvaluationState eval.State @@ -311,3 +330,11 @@ func takeImage(ctx context.Context, s image.ImageService, r *models.AlertRule) ( } return img, nil } + +func FormatStateAndReason(state eval.State, reason string) string { + s := fmt.Sprintf("%v", state) + if len(reason) > 0 { + s += fmt.Sprintf(" (%v)", reason) + } + return s +} diff --git a/pkg/services/ngalert/state/testing.go b/pkg/services/ngalert/state/testing.go index b13029b3967..c003ba446ff 100644 --- a/pkg/services/ngalert/state/testing.go +++ b/pkg/services/ngalert/state/testing.go @@ -3,7 +3,6 @@ package state import ( "context" "sync" - "time" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -49,5 +48,5 @@ func (f *FakeRuleReader) ListAlertRules(_ context.Context, q *models.ListAlertRu type FakeHistorian struct{} -func (f *FakeHistorian) RecordState(ctx context.Context, rule *models.AlertRule, currentState *State, evaluatedAt time.Time, currentData, previousData InstanceStateAndReason) { +func (f *FakeHistorian) RecordStates(ctx context.Context, rule *models.AlertRule, states []StateTransition) { } From 4d2bf41efbfb7bc940273d558bf5f05293e69552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 4 Nov 2022 16:49:31 +0100 Subject: [PATCH 052/926] AppChrome: Fixes kiosk mode toggling (#58240) --- .../components/AppChrome/AppChromeService.test.tsx | 10 ++++++++++ .../app/core/components/AppChrome/AppChromeService.tsx | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 public/app/core/components/AppChrome/AppChromeService.test.tsx diff --git a/public/app/core/components/AppChrome/AppChromeService.test.tsx b/public/app/core/components/AppChrome/AppChromeService.test.tsx new file mode 100644 index 00000000000..ea04bddf3bc --- /dev/null +++ b/public/app/core/components/AppChrome/AppChromeService.test.tsx @@ -0,0 +1,10 @@ +import { AppChromeService } from './AppChromeService'; + +describe('AppChromeService', () => { + it('onToggleKioskMode should set chromeless to true when searchbar is hidden', () => { + const chromeService = new AppChromeService(); + chromeService.onToggleSearchBar(); + chromeService.onToggleKioskMode(); + expect(chromeService.state.getValue().chromeless).toBe(true); + }); +}); diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 92d05a19b2a..54efe9e132b 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -57,11 +57,11 @@ export class AppChromeService { this.routeChangeHandled = true; } + Object.assign(newState, update); + // KioskMode overrides chromeless state newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless; - Object.assign(newState, update); - if (!isShallowEqual(current, newState)) { this.state.next(newState); } From d581b368bdeeaf8870e495a15dcfdf5aef293be9 Mon Sep 17 00:00:00 2001 From: Will Jordan Date: Fri, 4 Nov 2022 09:09:24 -0700 Subject: [PATCH 053/926] Alerting: Remove duplicate Slack notification title (#58107) Move mentions to a markdown-formatted pretext field to prevent issues mixing blocks and legacy-attachment content. --- .../ngalert/notifier/channels/slack.go | 17 ++++------- .../ngalert/notifier/channels/slack_test.go | 5 ---- .../alerting/api_notification_channel_test.go | 28 ++++--------------- 3 files changed, 12 insertions(+), 38 deletions(-) diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 199725a1c37..4b424a0f40a 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -132,7 +132,7 @@ type slackMessage struct { IconEmoji string `json:"icon_emoji,omitempty"` IconURL string `json:"icon_url,omitempty"` Attachments []attachment `json:"attachments"` - Blocks []map[string]interface{} `json:"blocks"` + Blocks []map[string]interface{} `json:"blocks,omitempty"` } // attachment is used to display a richly-formatted message block. @@ -147,6 +147,8 @@ type attachment struct { FooterIcon string `json:"footer_icon"` Color string `json:"color,omitempty"` Ts int64 `json:"ts,omitempty"` + Pretext string `json:"pretext,omitempty"` + MrkdwnIn []string `json:"mrkdwn_in,omitempty"` } // Notify sends an alert notification to Slack. @@ -261,7 +263,6 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.A req := &slackMessage{ Channel: tmpl(sn.settings.Recipient), - Text: tmpl(sn.settings.Title), Username: tmpl(sn.settings.Username), IconEmoji: tmpl(sn.settings.IconEmoji), IconURL: tmpl(sn.settings.IconURL), @@ -315,15 +316,9 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, alrts []*types.A } if mentionsBuilder.Len() > 0 { - req.Blocks = []map[string]interface{}{ - { - "type": "section", - "text": map[string]interface{}{ - "type": "mrkdwn", - "text": mentionsBuilder.String(), - }, - }, - } + // Use markdown-formatted pretext for any mentions. + req.Attachments[0].MrkdwnIn = []string{"pretext"} + req.Attachments[0].Pretext = mentionsBuilder.String() } return req, nil diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index f8405c7b646..e57a3e95e72 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -64,7 +64,6 @@ func TestSlackNotifier(t *testing.T) { }, expMsg: &slackMessage{ Channel: "#testchannel", - Text: "[FIRING:1] (val1)", Username: "Grafana", IconEmoji: ":emoji:", Attachments: []attachment{ @@ -100,7 +99,6 @@ func TestSlackNotifier(t *testing.T) { }, expMsg: &slackMessage{ Channel: "#testchannel", - Text: "[FIRING:1] (val1)", Username: "Grafana", IconEmoji: ":emoji:", Attachments: []attachment{ @@ -136,7 +134,6 @@ func TestSlackNotifier(t *testing.T) { }, expMsg: &slackMessage{ Channel: "#testchannel", - Text: "[FIRING:1] (val1)", Username: "Grafana", IconEmoji: ":emoji:", Attachments: []attachment{ @@ -180,7 +177,6 @@ func TestSlackNotifier(t *testing.T) { }, expMsg: &slackMessage{ Channel: "#testchannel", - Text: "2 firing, 0 resolved", Username: "Grafana", IconEmoji: ":emoji:", Attachments: []attachment{ @@ -229,7 +225,6 @@ func TestSlackNotifier(t *testing.T) { }, expMsg: &slackMessage{ Channel: "#testchannel", - Text: "[FIRING:1] (val1)", Username: "Grafana", IconEmoji: ":emoji:", Attachments: []attachment{ diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 1d6f9c3dbed..c520fa07bf4 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2312,7 +2312,6 @@ var expNonEmailNotifications = map[string][]string{ "slack_recv1/slack_test_without_token": { `{ "channel": "#test-channel", - "text": "Integration Test [FIRING:1] SlackAlert1 (default)", "username": "Integration Test", "icon_emoji": "🚀", "icon_url": "https://awesomeemoji.com/rocket", @@ -2325,16 +2324,9 @@ var expNonEmailNotifications = map[string][]string{ "footer": "Grafana v", "footer_icon": "https://grafana.com/assets/img/fav32.png", "color": "#D63232", - "ts": %s - } - ], - "blocks": [ - { - "text": { - "text": " <@user1><@user2>", - "type": "mrkdwn" - }, - "type": "section" + "ts": %s, + "mrkdwn_in": ["pretext"], + "pretext": " <@user1><@user2>" } ] }`, @@ -2342,7 +2334,6 @@ var expNonEmailNotifications = map[string][]string{ "slack_recvX/slack_testX": { `{ "channel": "#test-channel", - "text": "[FIRING:1] SlackAlert2 (default)", "username": "Integration Test", "attachments": [ { @@ -2353,16 +2344,9 @@ var expNonEmailNotifications = map[string][]string{ "footer": "Grafana v", "footer_icon": "https://grafana.com/assets/img/fav32.png", "color": "#D63232", - "ts": %s - } - ], - "blocks": [ - { - "text": { - "text": "<@user1><@user2>", - "type": "mrkdwn" - }, - "type": "section" + "ts": %s, + "mrkdwn_in": ["pretext"], + "pretext": "<@user1><@user2>" } ] }`, From 22628d1f7eedca2ce7fa6cd303fafdb6798dc93a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 4 Nov 2022 09:37:25 -0700 Subject: [PATCH 054/926] Storage: fix failing test (set IsServiceAccount=true) (#58257) --- pkg/services/store/object/tests/common.go | 11 ++++++----- .../store/object/tests/server_integration_test.go | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/services/store/object/tests/common.go b/pkg/services/store/object/tests/common.go index 1b8afffa11a..29dfd27d752 100644 --- a/pkg/services/store/object/tests/common.go +++ b/pkg/services/store/object/tests/common.go @@ -42,11 +42,12 @@ func createServiceAccountAdminToken(t *testing.T, env *server.TestEnv) (string, }) return keyGen.ClientSecret, &user.SignedInUser{ - UserID: account.ID, - Email: account.Email, - Name: account.Name, - Login: account.Login, - OrgID: account.OrgID, + UserID: account.ID, + Email: account.Email, + Name: account.Name, + Login: account.Login, + OrgID: account.OrgID, + IsServiceAccount: account.IsServiceAccount, } } diff --git a/pkg/services/store/object/tests/server_integration_test.go b/pkg/services/store/object/tests/server_integration_test.go index bc430d0e0ee..24087ae9bcc 100644 --- a/pkg/services/store/object/tests/server_integration_test.go +++ b/pkg/services/store/object/tests/server_integration_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/object" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/require" @@ -120,7 +121,7 @@ func TestIntegrationObjectServer(t *testing.T) { testCtx := createTestContext(t) ctx := metadata.AppendToOutgoingContext(testCtx.ctx, "authorization", fmt.Sprintf("Bearer %s", testCtx.authToken)) - fakeUser := fmt.Sprintf("user:%d:%s", testCtx.user.UserID, testCtx.user.Login) + fakeUser := store.GetUserIDString(testCtx.user) firstVersion := "1" kind := models.StandardKindJSONObj grn := &object.GRN{ From ca3bcc691c0b67ab478535920d5bacaa08da6274 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 4 Nov 2022 16:42:34 +0000 Subject: [PATCH 055/926] hide sectionnav items when collapsed, ensure focus ring overlays active items (#58250) --- public/app/core/components/PageNew/SectionNav.tsx | 2 ++ public/app/core/components/PageNew/SectionNavItem.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/public/app/core/components/PageNew/SectionNav.tsx b/public/app/core/components/PageNew/SectionNav.tsx index db0abc1b6ff..d44fd9171ec 100644 --- a/public/app/core/components/PageNew/SectionNav.tsx +++ b/public/app/core/components/PageNew/SectionNav.tsx @@ -72,6 +72,7 @@ const getStyles = (theme: GrafanaTheme2) => { flexShrink: 0, transition: theme.transitions.create(['width', 'max-height']), maxHeight: 0, + visibility: 'hidden', [theme.breakpoints.up('md')]: { width: 0, maxHeight: 'unset', @@ -79,6 +80,7 @@ const getStyles = (theme: GrafanaTheme2) => { }), navExpanded: css({ maxHeight: '50vh', + visibility: 'visible', [theme.breakpoints.up('md')]: { width: '250px', maxHeight: 'unset', diff --git a/public/app/core/components/PageNew/SectionNavItem.tsx b/public/app/core/components/PageNew/SectionNavItem.tsx index 15fcff83259..a615a9026ac 100644 --- a/public/app/core/components/PageNew/SectionNavItem.tsx +++ b/public/app/core/components/PageNew/SectionNavItem.tsx @@ -66,6 +66,7 @@ const getStyles = (theme: GrafanaTheme2) => { &:hover, &:focus { text-decoration: underline; + z-index: 1; } `, activeStyle: css` From 10ee9f129d1df07ec5fe806be453d2df821d73ac Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:52:15 +0000 Subject: [PATCH 056/926] Update dependency rollup-plugin-node-externals to v5 (#58259) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-e2e/package.json | 2 +- packages/grafana-runtime/package.json | 2 +- packages/grafana-schema/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 24 ++++++++++----------- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 8656394717e..9994732e095 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -86,7 +86,7 @@ "rollup": "2.79.1", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "4.10.1", - "rollup-plugin-node-externals": "^4.1.0", + "rollup-plugin-node-externals": "^5.0.0", "sinon": "14.0.1", "typescript": "4.8.4" }, diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index cb674092a0c..9666955d259 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -45,7 +45,7 @@ "rollup": "2.79.1", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "4.10.1", - "rollup-plugin-node-externals": "^4.1.0" + "rollup-plugin-node-externals": "^5.0.0" }, "dependencies": { "@grafana/tsconfig": "^1.2.0-rc1", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 4bb67d329f2..4b61a859111 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -54,7 +54,7 @@ "rollup": "2.79.1", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "4.10.1", - "rollup-plugin-node-externals": "^4.1.0", + "rollup-plugin-node-externals": "^5.0.0", "webpack": "5.74.0" }, "dependencies": { diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index cead91578a1..0a18b91559a 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -69,7 +69,7 @@ "rollup": "2.79.1", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "4.10.1", - "rollup-plugin-node-externals": "^4.1.0", + "rollup-plugin-node-externals": "^5.0.0", "rollup-plugin-sourcemaps": "0.6.3", "rollup-plugin-terser": "7.0.2", "typescript": "4.8.4" diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 66c550de180..4a1583dbc85 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -44,7 +44,7 @@ "rollup": "2.79.1", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "4.10.1", - "rollup-plugin-node-externals": "^4.1.0", + "rollup-plugin-node-externals": "^5.0.0", "typescript": "4.8.4" }, "dependencies": { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index e3462c41bd9..c40cbab261b 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -185,7 +185,7 @@ "rollup": "2.79.1", "rollup-plugin-dts": "^4.2.2", "rollup-plugin-esbuild": "4.10.1", - "rollup-plugin-node-externals": "^4.1.0", + "rollup-plugin-node-externals": "^5.0.0", "rollup-plugin-svg-import": "^1.6.0", "sass-loader": "13.1.0", "storybook-addon-turbo-build": "1.1.0", diff --git a/yarn.lock b/yarn.lock index 19ff6dc2fd6..6cc0e62c207 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4371,7 +4371,7 @@ __metadata: rollup: 2.79.1 rollup-plugin-dts: ^4.2.2 rollup-plugin-esbuild: 4.10.1 - rollup-plugin-node-externals: ^4.1.0 + rollup-plugin-node-externals: ^5.0.0 rxjs: 7.5.7 sinon: 14.0.1 tinycolor2: 1.4.2 @@ -4398,7 +4398,7 @@ __metadata: rollup: 2.79.1 rollup-plugin-dts: ^4.2.2 rollup-plugin-esbuild: 4.10.1 - rollup-plugin-node-externals: ^4.1.0 + rollup-plugin-node-externals: ^5.0.0 tslib: 2.4.1 typescript: 4.8.4 languageName: unknown @@ -4435,7 +4435,7 @@ __metadata: rollup: 2.79.1 rollup-plugin-dts: ^4.2.2 rollup-plugin-esbuild: 4.10.1 - rollup-plugin-node-externals: ^4.1.0 + rollup-plugin-node-externals: ^5.0.0 tracelib: 1.0.1 ts-loader: 8.4.0 tslib: 2.4.1 @@ -4539,7 +4539,7 @@ __metadata: rollup: 2.79.1 rollup-plugin-dts: ^4.2.2 rollup-plugin-esbuild: 4.10.1 - rollup-plugin-node-externals: ^4.1.0 + rollup-plugin-node-externals: ^5.0.0 rollup-plugin-sourcemaps: 0.6.3 rollup-plugin-terser: 7.0.2 rxjs: 7.5.7 @@ -4566,7 +4566,7 @@ __metadata: rollup: 2.79.1 rollup-plugin-dts: ^4.2.2 rollup-plugin-esbuild: 4.10.1 - rollup-plugin-node-externals: ^4.1.0 + rollup-plugin-node-externals: ^5.0.0 tslib: 2.4.1 typescript: 4.8.4 languageName: unknown @@ -4806,7 +4806,7 @@ __metadata: rollup: 2.79.1 rollup-plugin-dts: ^4.2.2 rollup-plugin-esbuild: 4.10.1 - rollup-plugin-node-externals: ^4.1.0 + rollup-plugin-node-externals: ^5.0.0 rollup-plugin-svg-import: ^1.6.0 rxjs: 7.5.7 sass-loader: 13.1.0 @@ -33943,14 +33943,12 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-node-externals@npm:^4.1.0": - version: 4.1.0 - resolution: "rollup-plugin-node-externals@npm:4.1.0" - dependencies: - find-up: ^5.0.0 +"rollup-plugin-node-externals@npm:^5.0.0": + version: 5.0.2 + resolution: "rollup-plugin-node-externals@npm:5.0.2" peerDependencies: - rollup: ^2.60.0 - checksum: 4e714dd5135ca84943b304893ed7498bac292773c056051a5fae700072257715cca48e2b5d5e3e23d1d6395eb2aaff4f8e5f6294fbbbfd6be4171049929b70ed + rollup: ^2.60.0 || ^3.0.0 + checksum: 5afefcccb37a2c54e836d7b5b97e0b9ab47a187cf9341fda22e580f84d2f87de2b1938c8005a8d38794cab6acfcf49c9121b30431641ac53928139ad020e3160 languageName: node linkType: hard From 85d15b62929443eb72f17e9f2c3407d7664974b5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:54:12 +0000 Subject: [PATCH 057/926] Update dependency uuid to v9 (#58260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-e2e/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 23 ++++++++++++++++------- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 236064b5abc..c258b484838 100644 --- a/package.json +++ b/package.json @@ -398,7 +398,7 @@ "tinycolor2": "1.4.2", "tslib": "2.4.1", "uplot": "1.6.22", - "uuid": "8.3.2", + "uuid": "9.0.0", "vendor": "link:./public/vendor", "visjs-network": "4.25.0", "whatwg-fetch": "3.6.2" diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 4b61a859111..b003dec2d73 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -80,7 +80,7 @@ "ts-loader": "8.4.0", "tslib": "2.4.1", "typescript": "4.8.4", - "uuid": "8.3.2", + "uuid": "9.0.0", "yaml": "^2.0.0" } } diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index c40cbab261b..43358371861 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -108,7 +108,7 @@ "tinycolor2": "1.4.2", "tslib": "2.4.1", "uplot": "1.6.22", - "uuid": "8.3.2" + "uuid": "9.0.0" }, "devDependencies": { "@babel/core": "7.19.6", diff --git a/yarn.lock b/yarn.lock index 6cc0e62c207..78d3b44e3f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4440,7 +4440,7 @@ __metadata: ts-loader: 8.4.0 tslib: 2.4.1 typescript: 4.8.4 - uuid: 8.3.2 + uuid: 9.0.0 webpack: 5.74.0 yaml: ^2.0.0 bin: @@ -4820,7 +4820,7 @@ __metadata: tslib: 2.4.1 typescript: 4.8.4 uplot: 1.6.22 - uuid: 8.3.2 + uuid: 9.0.0 webpack: 5.74.0 peerDependencies: react: ^16.8.0 || ^17.0.0 @@ -21812,7 +21812,7 @@ __metadata: tslib: 2.4.1 typescript: 4.8.4 uplot: 1.6.22 - uuid: 8.3.2 + uuid: 9.0.0 vendor: "link:./public/vendor" visjs-network: 4.25.0 wait-on: 6.0.1 @@ -37989,12 +37989,12 @@ __metadata: languageName: node linkType: hard -"uuid@npm:8.3.2, uuid@npm:^8.3.2": - version: 8.3.2 - resolution: "uuid@npm:8.3.2" +"uuid@npm:9.0.0": + version: 9.0.0 + resolution: "uuid@npm:9.0.0" bin: uuid: dist/bin/uuid - checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df + checksum: 8dd2c83c43ddc7e1c71e36b60aea40030a6505139af6bee0f382ebcd1a56f6cd3028f7f06ffb07f8cf6ced320b76aea275284b224b002b289f89fe89c389b028 languageName: node linkType: hard @@ -38007,6 +38007,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^8.3.2": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df + languageName: node + linkType: hard + "uvu@npm:^0.5.0": version: 0.5.6 resolution: "uvu@npm:0.5.6" From 5f5b3521d93a13acee2948dcdb4da4f8653148ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Nov 2022 17:04:00 +0000 Subject: [PATCH 058/926] Update dependency rollup-plugin-dts to v5 (#58258) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-e2e/package.json | 2 +- packages/grafana-runtime/package.json | 2 +- packages/grafana-schema/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 37 ++++++++------------- 7 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 9994732e095..35e511e0a46 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -84,7 +84,7 @@ "react-test-renderer": "17.0.2", "rimraf": "3.0.2", "rollup": "2.79.1", - "rollup-plugin-dts": "^4.2.2", + "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "4.10.1", "rollup-plugin-node-externals": "^5.0.0", "sinon": "14.0.1", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 9666955d259..b6dafa85589 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -43,7 +43,7 @@ "esbuild": "0.15.12", "rimraf": "3.0.2", "rollup": "2.79.1", - "rollup-plugin-dts": "^4.2.2", + "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "4.10.1", "rollup-plugin-node-externals": "^5.0.0" }, diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index b003dec2d73..8ef80c372f9 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -52,7 +52,7 @@ "@types/uuid": "8.3.4", "esbuild": "0.15.12", "rollup": "2.79.1", - "rollup-plugin-dts": "^4.2.2", + "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "4.10.1", "rollup-plugin-node-externals": "^5.0.0", "webpack": "5.74.0" diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 0a18b91559a..02c7a0d9e33 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -67,7 +67,7 @@ "react-dom": "17.0.2", "rimraf": "3.0.2", "rollup": "2.79.1", - "rollup-plugin-dts": "^4.2.2", + "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "4.10.1", "rollup-plugin-node-externals": "^5.0.0", "rollup-plugin-sourcemaps": "0.6.3", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 4a1583dbc85..d2c08dfe2b1 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -42,7 +42,7 @@ "esbuild": "0.15.12", "rimraf": "3.0.2", "rollup": "2.79.1", - "rollup-plugin-dts": "^4.2.2", + "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "4.10.1", "rollup-plugin-node-externals": "^5.0.0", "typescript": "4.8.4" diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 43358371861..b6b9691ca9f 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -183,7 +183,7 @@ "react-test-renderer": "17.0.2", "rimraf": "3.0.2", "rollup": "2.79.1", - "rollup-plugin-dts": "^4.2.2", + "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "4.10.1", "rollup-plugin-node-externals": "^5.0.0", "rollup-plugin-svg-import": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 78d3b44e3f4..b4e12cf68a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4369,7 +4369,7 @@ __metadata: regenerator-runtime: 0.13.10 rimraf: 3.0.2 rollup: 2.79.1 - rollup-plugin-dts: ^4.2.2 + rollup-plugin-dts: ^5.0.0 rollup-plugin-esbuild: 4.10.1 rollup-plugin-node-externals: ^5.0.0 rxjs: 7.5.7 @@ -4396,7 +4396,7 @@ __metadata: esbuild: 0.15.12 rimraf: 3.0.2 rollup: 2.79.1 - rollup-plugin-dts: ^4.2.2 + rollup-plugin-dts: ^5.0.0 rollup-plugin-esbuild: 4.10.1 rollup-plugin-node-externals: ^5.0.0 tslib: 2.4.1 @@ -4433,7 +4433,7 @@ __metadata: resolve-as-bin: 2.1.0 rimraf: 3.0.2 rollup: 2.79.1 - rollup-plugin-dts: ^4.2.2 + rollup-plugin-dts: ^5.0.0 rollup-plugin-esbuild: 4.10.1 rollup-plugin-node-externals: ^5.0.0 tracelib: 1.0.1 @@ -4537,7 +4537,7 @@ __metadata: react-dom: 17.0.2 rimraf: 3.0.2 rollup: 2.79.1 - rollup-plugin-dts: ^4.2.2 + rollup-plugin-dts: ^5.0.0 rollup-plugin-esbuild: 4.10.1 rollup-plugin-node-externals: ^5.0.0 rollup-plugin-sourcemaps: 0.6.3 @@ -4564,7 +4564,7 @@ __metadata: esbuild: 0.15.12 rimraf: 3.0.2 rollup: 2.79.1 - rollup-plugin-dts: ^4.2.2 + rollup-plugin-dts: ^5.0.0 rollup-plugin-esbuild: 4.10.1 rollup-plugin-node-externals: ^5.0.0 tslib: 2.4.1 @@ -4804,7 +4804,7 @@ __metadata: react-window: 1.8.8 rimraf: 3.0.2 rollup: 2.79.1 - rollup-plugin-dts: ^4.2.2 + rollup-plugin-dts: ^5.0.0 rollup-plugin-esbuild: 4.10.1 rollup-plugin-node-externals: ^5.0.0 rollup-plugin-svg-import: ^1.6.0 @@ -26477,16 +26477,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.26.1": - version: 0.26.2 - resolution: "magic-string@npm:0.26.2" - dependencies: - sourcemap-codec: ^1.4.8 - checksum: b4db4e2b370ac8d9ffc6443a2b591b75364bf1fc9121b5a4068d5b89804abff6709d1fa4a0e0c2d54f2e61e0e44db83efdfe219a5ab0ba6d25ee1f2b51fbed55 - languageName: node - linkType: hard - -"magic-string@npm:^0.26.4": +"magic-string@npm:^0.26.4, magic-string@npm:^0.26.7": version: 0.26.7 resolution: "magic-string@npm:0.26.7" dependencies: @@ -33911,19 +33902,19 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-dts@npm:^4.2.2": - version: 4.2.2 - resolution: "rollup-plugin-dts@npm:4.2.2" +"rollup-plugin-dts@npm:^5.0.0": + version: 5.0.0 + resolution: "rollup-plugin-dts@npm:5.0.0" dependencies: - "@babel/code-frame": ^7.16.7 - magic-string: ^0.26.1 + "@babel/code-frame": ^7.18.6 + magic-string: ^0.26.7 peerDependencies: - rollup: ^2.55 + rollup: ^3.0.0 typescript: ^4.1 dependenciesMeta: "@babel/code-frame": optional: true - checksum: cf4b45f6cca442a5f44af0f0fb567c8fc540ecb792c763571d1bcda9bf495803bcc8d4eaef451a2dd32f7f391eb822e2b96cc6b86b096db54a4d3935236fd8da + checksum: feb2d614528c255ee698120be0fd404b0a4fa312362a3d687d76551157464decc66be6dfecb624c42bc64a9e6298ed21e13e689dc9b144acab8294cd08a53455 languageName: node linkType: hard From ae30a0688aff31137fa27a80c983155873eda148 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Fri, 4 Nov 2022 14:14:32 -0300 Subject: [PATCH 059/926] PublicDashboards: refactor using new grafana error types (#58078) --- pkg/services/publicdashboards/api/api.go | 64 ++-- pkg/services/publicdashboards/api/api_test.go | 290 +++++++++--------- pkg/services/publicdashboards/api/query.go | 18 +- .../publicdashboards/api/query_test.go | 18 +- .../publicdashboards/models/errors.go | 22 ++ .../publicdashboards/models/models.go | 37 --- .../publicdashboards/service/query.go | 10 +- .../publicdashboards/service/query_test.go | 2 +- .../publicdashboards/service/service.go | 61 ++-- .../publicdashboards/service/service_test.go | 35 ++- .../publicdashboards/validation/validation.go | 8 +- .../validation/validation_test.go | 2 +- 12 files changed, 267 insertions(+), 300 deletions(-) create mode 100644 pkg/services/publicdashboards/models/errors.go diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index 61e867ad287..1837911e62d 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -1,8 +1,6 @@ package api import ( - "context" - "errors" "net/http" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -89,50 +87,49 @@ func (api *Api) RegisterAPIEndpoints() { routing.Wrap(api.DeletePublicDashboard)) } -// ListPublicDashboards Gets list of public dashboards for an org -// GET /api/dashboards/public +// ListPublicDashboards Gets list of public dashboards by orgId +// GET /api/dashboards/public-dashboards func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { resp, err := api.PublicDashboardService.FindAll(c.Req.Context(), c.SignedInUser, c.OrgID) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "ListPublicDashboards: failed to list public dashboards", err) + return response.Err(err) } return response.JSON(http.StatusOK, resp) } // GetPublicDashboard Gets public dashboard for dashboard -// GET /api/dashboards/uid/:uid/public-dashboards +// GET /api/dashboards/uid/:dashboardUid/public-dashboards func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] if !tokens.IsValidShortUID(dashboardUid) { - api.handleError(c.Req.Context(), http.StatusBadRequest, "GetPublicDashboard: no valid dashboardUid", dashboards.ErrDashboardIdentifierNotSet) + return response.Err(ErrPublicDashboardIdentifierNotSet.Errorf("GetPublicDashboard: no dashboard Uid for public dashboard specified")) } - pd, err := api.PublicDashboardService.FindByDashboardUid(c.Req.Context(), c.OrgID, web.Params(c.Req)[":dashboardUid"]) - + pd, err := api.PublicDashboardService.FindByDashboardUid(c.Req.Context(), c.OrgID, dashboardUid) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "GetPublicDashboard: failed to get public dashboard ", err) + return response.Err(err) } if pd == nil { - return api.handleError(c.Req.Context(), http.StatusNotFound, "GetPublicDashboard: public dashboard not found", ErrPublicDashboardNotFound) + response.Err(ErrPublicDashboardNotFound.Errorf("GetPublicDashboard: public dashboard not found")) } return response.JSON(http.StatusOK, pd) } // CreatePublicDashboard Sets public dashboard for dashboard -// POST /api/dashboards/uid/:uid/public-dashboards +// POST /api/dashboards/uid/:dashboardUid/public-dashboards func (api *Api) CreatePublicDashboard(c *models.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] if !tokens.IsValidShortUID(dashboardUid) { - return api.handleError(c.Req.Context(), http.StatusBadRequest, "CreatePublicDashboard: invalid dashboardUid", dashboards.ErrDashboardIdentifierInvalid) + return response.Err(ErrInvalidUid.Errorf("CreatePublicDashboard: invalid Uid %s", dashboardUid)) } pd := &PublicDashboard{} if err := web.Bind(c.Req, pd); err != nil { - return api.handleError(c.Req.Context(), http.StatusBadRequest, "CreatePublicDashboard: bad request data", err) + return response.Err(ErrBadRequest.Errorf("CreatePublicDashboard: bad request data %v", err)) } // Always set the orgID and userID from the session @@ -147,29 +144,29 @@ func (api *Api) CreatePublicDashboard(c *models.ReqContext) response.Response { //Create the public dashboard pd, err := api.PublicDashboardService.Create(c.Req.Context(), c.SignedInUser, &dto) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "CreatePublicDashboard: failed to create public dashboard", err) + return response.Err(err) } return response.JSON(http.StatusOK, pd) } // UpdatePublicDashboard Sets public dashboard for dashboard -// PUT /api/dashboards/uid/:uid/public-dashboards +// PUT /api/dashboards/uid/:dashboardUid/public-dashboards/:uid func (api *Api) UpdatePublicDashboard(c *models.ReqContext) response.Response { // exit if we don't have a valid dashboardUid dashboardUid := web.Params(c.Req)[":dashboardUid"] if !tokens.IsValidShortUID(dashboardUid) { - return api.handleError(c.Req.Context(), http.StatusBadRequest, "UpdatePublicDashboard: invalid dashboardUid", dashboards.ErrDashboardIdentifierInvalid) + return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid dashboard Uid %s", dashboardUid)) } uid := web.Params(c.Req)[":uid"] if !tokens.IsValidShortUID(uid) { - return api.handleError(c.Req.Context(), http.StatusBadRequest, "UpdatePublicDashboard: invalid public dashboard uid", ErrPublicDashboardIdentifierNotSet) + return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid Uid %s", uid)) } pd := &PublicDashboard{} if err := web.Bind(c.Req, pd); err != nil { - return api.handleError(c.Req.Context(), http.StatusBadRequest, "UpdatePublicDashboard: bad request data", err) + return response.Err(ErrBadRequest.Errorf("UpdatePublicDashboard: bad request data %v", err)) } // Always set the orgID and userID from the session @@ -182,10 +179,10 @@ func (api *Api) UpdatePublicDashboard(c *models.ReqContext) response.Response { PublicDashboard: pd, } - // Save the public dashboard + // Update the public dashboard pd, err := api.PublicDashboardService.Update(c.Req.Context(), c.SignedInUser, &dto) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "UpdatePublicDashboard: failed to update public dashboard", err) + return response.Err(err) } return response.JSON(http.StatusOK, pd) @@ -196,38 +193,17 @@ func (api *Api) UpdatePublicDashboard(c *models.ReqContext) response.Response { func (api *Api) DeletePublicDashboard(c *models.ReqContext) response.Response { uid := web.Params(c.Req)[":uid"] if !tokens.IsValidShortUID(uid) { - return api.handleError(c.Req.Context(), http.StatusBadRequest, "DeletePublicDashboard: invalid dashboard uid", dashboards.ErrDashboardIdentifierNotSet) + return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid Uid %s", uid)) } err := api.PublicDashboardService.Delete(c.Req.Context(), c.OrgID, uid) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "DeletePublicDashboard: failed to delete public dashboard", err) + return response.Err(err) } return response.JSON(http.StatusOK, nil) } -// util to help us unpack dashboard and publicdashboard errors or use default http code and message -// we should look to do some future refactoring of these errors as publicdashboard err is the same as a dashboarderr, just defined in a -// different package. -func (api *Api) handleError(ctx context.Context, code int, message string, err error) response.Response { - var publicDashboardErr PublicDashboardErr - ctxLogger := api.Log.FromContext(ctx) - ctxLogger.Error(message, "error", err.Error()) - - // handle public dashboard error - if ok := errors.As(err, &publicDashboardErr); ok { - return response.Error(publicDashboardErr.StatusCode, publicDashboardErr.Error(), publicDashboardErr) - } - - var dashboardErr dashboards.DashboardErr - if ok := errors.As(err, &dashboardErr); ok { - return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), dashboardErr) - } - - return response.Error(code, message, err) -} - // Copied from pkg/api/metrics.go func toJsonStreamingResponse(features *featuremgmt.FeatureManager, qdr *backend.QueryDataResponse) response.Response { statusWhenError := http.StatusBadRequest diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index 25b279cf978..226274d7f1b 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -8,6 +8,10 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" @@ -15,9 +19,7 @@ import ( . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/util/errutil" ) var userAdmin = &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleAdmin, Login: "testAdminUser"} @@ -121,7 +123,7 @@ func TestAPIListPublicDashboard(t *testing.T) { Name: "Handles Service error", User: userViewer, Response: nil, - ResponseErr: errors.New("error, service broken"), + ResponseErr: ErrInternalServerError.Errorf(""), ExpectedHttpResponse: http.StatusInternalServerError, }, } @@ -148,16 +150,150 @@ func TestAPIListPublicDashboard(t *testing.T) { } if test.ResponseErr != nil { - var errResp JsonErrResponse + var errResp errutil.PublicError err := json.Unmarshal(response.Body.Bytes(), &errResp) require.NoError(t, err) - assert.Equal(t, "error, service broken", errResp.Error) + assert.Equal(t, "Internal server error", errResp.Message) + assert.Equal(t, "publicdashboards.internalServerError", errResp.MessageID) service.AssertNotCalled(t, "FindAll") } }) } } +func TestAPIDeletePublicDashboard(t *testing.T) { + dashboardUid := "abc1234" + publicDashboardUid := "1234asdfasdf" + userEditorAllPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {dashboards.ScopeDashboardsAll}}}} + userEditorAnotherPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {"another-uid"}}}} + userEditorPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {fmt.Sprintf("dashboards:uid:%s", dashboardUid)}}}} + + testCases := []struct { + Name string + User *user.SignedInUser + DashboardUid string + PublicDashboardUid string + ResponseErr error + ExpectedHttpResponse int + ExpectedMessageResponse string + ShouldCallService bool + }{ + { + Name: "User viewer cannot delete public dashboard", + User: userViewer, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + ResponseErr: nil, + ExpectedHttpResponse: http.StatusForbidden, + ShouldCallService: false, + }, + { + Name: "User editor without specific dashboard access cannot delete public dashboard", + User: userEditorAnotherPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + ResponseErr: nil, + ExpectedHttpResponse: http.StatusForbidden, + ShouldCallService: false, + }, + { + Name: "User editor with all dashboard accesses can delete public dashboard", + User: userEditorAllPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + ResponseErr: nil, + ExpectedHttpResponse: http.StatusOK, + ShouldCallService: true, + }, + { + Name: "User editor with dashboard access can delete public dashboard", + User: userEditorPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + ResponseErr: nil, + ExpectedHttpResponse: http.StatusOK, + ShouldCallService: true, + }, + { + Name: "Internal server error returns an error", + User: userEditorPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + ResponseErr: ErrInternalServerError.Errorf(""), + ExpectedHttpResponse: ErrInternalServerError.Errorf("").Reason.Status().HTTPStatus(), + ExpectedMessageResponse: ErrInternalServerError.Errorf("").PublicMessage, + ShouldCallService: true, + }, + { + Name: "PublicDashboard error returns correct status code instead of 500", + User: userEditorPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: publicDashboardUid, + ResponseErr: ErrPublicDashboardIdentifierNotSet.Errorf(""), + ExpectedHttpResponse: ErrPublicDashboardIdentifierNotSet.Errorf("").Reason.Status().HTTPStatus(), + ExpectedMessageResponse: ErrPublicDashboardIdentifierNotSet.Errorf("").PublicMessage, + ShouldCallService: true, + }, + { + Name: "Invalid publicDashboardUid throws an error", + User: userEditorPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: "inv@lid-publicd@shboard-uid!", + ResponseErr: nil, + ExpectedHttpResponse: http.StatusBadRequest, + ShouldCallService: false, + }, + { + Name: "Public dashboard uid does not exist", + User: userEditorPublicDashboard, + DashboardUid: dashboardUid, + PublicDashboardUid: "UIDDOESNOTEXIST", + ResponseErr: ErrPublicDashboardNotFound.Errorf(""), + ExpectedHttpResponse: ErrPublicDashboardNotFound.Errorf("").Reason.Status().HTTPStatus(), + ExpectedMessageResponse: ErrPublicDashboardNotFound.Errorf("").PublicMessage, + ShouldCallService: true, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + + if test.ShouldCallService { + service.On("Delete", mock.Anything, mock.Anything, mock.Anything). + Return(test.ResponseErr) + } + + cfg := setting.NewCfg() + + features := featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards) + testServer := setupTestServer(t, cfg, features, service, nil, test.User) + + response := callAPI(testServer, http.MethodDelete, fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", test.DashboardUid, test.PublicDashboardUid), nil, t) + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + if test.ExpectedHttpResponse == http.StatusOK { + var jsonResp any + err := json.Unmarshal(response.Body.Bytes(), &jsonResp) + require.NoError(t, err) + assert.Equal(t, jsonResp, nil) + } + + if !test.ShouldCallService { + service.AssertNotCalled(t, "Delete") + } + + if test.ResponseErr != nil { + var errResp errutil.PublicError + err := json.Unmarshal(response.Body.Bytes(), &errResp) + require.NoError(t, err) + assert.Equal(t, test.ExpectedHttpResponse, errResp.StatusCode) + assert.Equal(t, test.ExpectedMessageResponse, errResp.Message) + } + }) + } +} + func TestAPIGetPublicDashboard(t *testing.T) { pubdash := &PublicDashboard{IsEnabled: true} @@ -186,7 +322,7 @@ func TestAPIGetPublicDashboard(t *testing.T) { DashboardUid: "77777", ExpectedHttpResponse: http.StatusNotFound, PublicDashboardResult: nil, - PublicDashboardErr: dashboards.ErrDashboardNotFound, + PublicDashboardErr: ErrDashboardNotFound.Errorf(""), User: userViewer, AccessControlEnabled: false, ShouldCallService: true, @@ -288,7 +424,7 @@ func TestApiCreatePublicDashboard(t *testing.T) { Name: "returns 500 when not persisted", ExpectedHttpResponse: http.StatusInternalServerError, publicDashboard: &PublicDashboard{}, - SaveDashboardErr: errors.New("backend failed to save"), + SaveDashboardErr: ErrInternalServerError.Errorf(""), User: userAdmin, AccessControlEnabled: false, ShouldCallService: true, @@ -297,7 +433,7 @@ func TestApiCreatePublicDashboard(t *testing.T) { Name: "returns 404 when dashboard not found", ExpectedHttpResponse: http.StatusNotFound, publicDashboard: &PublicDashboard{}, - SaveDashboardErr: dashboards.ErrDashboardNotFound, + SaveDashboardErr: ErrDashboardNotFound.Errorf(""), User: userAdmin, AccessControlEnabled: false, ShouldCallService: true, @@ -316,7 +452,7 @@ func TestApiCreatePublicDashboard(t *testing.T) { Name: "returns 403 when no permissions", ExpectedHttpResponse: http.StatusForbidden, publicDashboard: &PublicDashboard{IsEnabled: true}, - SaveDashboardErr: nil, + SaveDashboardErr: ErrInternalServerError.Errorf("default error"), User: userViewer, AccessControlEnabled: false, ShouldCallService: false, @@ -400,7 +536,7 @@ func TestAPIUpdatePublicDashboard(t *testing.T) { DashboardUid: "", PublicDashboardUid: "", PublicDashboardRes: nil, - PublicDashboardErr: dashboards.ErrDashboardIdentifierInvalid, + PublicDashboardErr: ErrPublicDashboardIdentifierNotSet.Errorf(""), ExpectedHttpResponse: http.StatusNotFound, ShouldCallService: false, }, @@ -410,7 +546,7 @@ func TestAPIUpdatePublicDashboard(t *testing.T) { DashboardUid: dashboardUid, PublicDashboardUid: "", PublicDashboardRes: nil, - PublicDashboardErr: ErrPublicDashboardNotFound, + PublicDashboardErr: ErrPublicDashboardNotFound.Errorf(""), ExpectedHttpResponse: http.StatusNotFound, ShouldCallService: false, }, @@ -420,7 +556,7 @@ func TestAPIUpdatePublicDashboard(t *testing.T) { DashboardUid: dashboardUid, PublicDashboardUid: publicDashboardUid, PublicDashboardRes: nil, - PublicDashboardErr: dashboards.ErrDashboardNotFound, + PublicDashboardErr: ErrDashboardNotFound.Errorf(""), ExpectedHttpResponse: http.StatusNotFound, ShouldCallService: true, }, @@ -505,131 +641,3 @@ func TestAPIUpdatePublicDashboard(t *testing.T) { }) } } - -func TestAPIDeletePublicDashboard(t *testing.T) { - dashboardUid := "abc1234" - publicDashboardUid := "1234asdfasdf" - userEditorAllPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {dashboards.ScopeDashboardsAll}}}} - userEditorAnotherPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {"another-uid"}}}} - userEditorPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {fmt.Sprintf("dashboards:uid:%s", dashboardUid)}}}} - - testCases := []struct { - Name string - User *user.SignedInUser - DashboardUid string - PublicDashboardUid string - ResponseErr error - ExpectedHttpResponse int - ShouldCallService bool - }{ - { - Name: "User viewer cannot delete public dashboard", - User: userViewer, - DashboardUid: dashboardUid, - PublicDashboardUid: publicDashboardUid, - ResponseErr: nil, - ExpectedHttpResponse: http.StatusForbidden, - ShouldCallService: false, - }, - { - Name: "User editor without specific dashboard access cannot delete public dashboard", - User: userEditorAnotherPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: publicDashboardUid, - ResponseErr: nil, - ExpectedHttpResponse: http.StatusForbidden, - ShouldCallService: false, - }, - { - Name: "User editor with all dashboard accesses can delete public dashboard", - User: userEditorAllPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: publicDashboardUid, - ResponseErr: nil, - ExpectedHttpResponse: http.StatusOK, - ShouldCallService: true, - }, - { - Name: "User editor with dashboard access can delete public dashboard", - User: userEditorPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: publicDashboardUid, - ResponseErr: nil, - ExpectedHttpResponse: http.StatusOK, - ShouldCallService: true, - }, - { - Name: "Internal server error returns an error", - User: userEditorPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: publicDashboardUid, - ResponseErr: errors.New("server error"), - ExpectedHttpResponse: http.StatusInternalServerError, - ShouldCallService: true, - }, - { - Name: "PublicDashboard error returns correct status code instead of 500", - User: userEditorPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: publicDashboardUid, - ResponseErr: ErrPublicDashboardIdentifierNotSet, - ExpectedHttpResponse: ErrPublicDashboardIdentifierNotSet.StatusCode, - ShouldCallService: true, - }, - { - Name: "Invalid publicDashboardUid throws an error", - User: userEditorPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: "inv@lid-publicd@shboard-uid!", - ResponseErr: nil, - ExpectedHttpResponse: ErrPublicDashboardIdentifierNotSet.StatusCode, - ShouldCallService: false, - }, - { - Name: "Public dashboard uid does not exist", - User: userEditorPublicDashboard, - DashboardUid: dashboardUid, - PublicDashboardUid: "UIDDOESNOTEXIST", - ResponseErr: ErrPublicDashboardNotFound, - ExpectedHttpResponse: ErrPublicDashboardNotFound.StatusCode, - ShouldCallService: true, - }, - } - - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - service := publicdashboards.NewFakePublicDashboardService(t) - - if test.ShouldCallService { - service.On("Delete", mock.Anything, mock.Anything, mock.Anything). - Return(test.ResponseErr) - } - - cfg := setting.NewCfg() - - features := featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards) - testServer := setupTestServer(t, cfg, features, service, nil, test.User) - - response := callAPI(testServer, http.MethodDelete, fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", test.DashboardUid, test.PublicDashboardUid), nil, t) - assert.Equal(t, test.ExpectedHttpResponse, response.Code) - - if test.ExpectedHttpResponse == http.StatusOK { - var jsonResp any - err := json.Unmarshal(response.Body.Bytes(), &jsonResp) - require.NoError(t, err) - assert.Equal(t, jsonResp, nil) - } - - if !test.ShouldCallService { - service.AssertNotCalled(t, "Delete") - } - - if test.ResponseErr != nil { - var errResp JsonErrResponse - err := json.Unmarshal(response.Body.Bytes(), &errResp) - require.NoError(t, err) - assert.Equal(t, test.ResponseErr.Error(), errResp.Error) - } - }) - } -} diff --git a/pkg/services/publicdashboards/api/query.go b/pkg/services/publicdashboards/api/query.go index bab07c64445..582bec042db 100644 --- a/pkg/services/publicdashboards/api/query.go +++ b/pkg/services/publicdashboards/api/query.go @@ -16,9 +16,8 @@ import ( // GET /api/public/dashboards/:accessToken func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] - if !tokens.IsValidAccessToken(accessToken) { - return response.Error(http.StatusBadRequest, "Invalid Access Token", nil) + return response.Err(ErrInvalidAccessToken.Errorf("ViewPublicDashboard: invalid access token")) } pubdash, dash, err := api.PublicDashboardService.FindPublicDashboardAndDashboardByAccessToken( @@ -26,7 +25,7 @@ func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response { accessToken, ) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "ViewPublicDashboard: failed to get public dashboard", err) + return response.Err(err) } meta := dtos.DashboardMeta{ @@ -56,22 +55,22 @@ func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response { func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] if !tokens.IsValidAccessToken(accessToken) { - return response.Error(http.StatusBadRequest, "Invalid Access Token", nil) + return response.Err(ErrInvalidAccessToken.Errorf("QueryPublicDashboard: invalid access token")) } panelId, err := strconv.ParseInt(web.Params(c.Req)[":panelId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "QueryPublicDashboard: invalid panel ID", err) + return response.Err(ErrInvalidPanelId.Errorf("QueryPublicDashboard: error parsing panelId %v", err)) } reqDTO := PublicDashboardQueryDTO{} if err = web.Bind(c.Req, &reqDTO); err != nil { - return response.Error(http.StatusBadRequest, "QueryPublicDashboard: bad request data", err) + return response.Err(ErrBadRequest.Errorf("QueryPublicDashboard: error parsing request: %v", err)) } resp, err := api.PublicDashboardService.GetQueryDataResponse(c.Req.Context(), c.SkipCache, reqDTO, panelId, accessToken) if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "QueryPublicDashboard: error running public dashboard panel queries", err) + return response.Err(err) } return toJsonStreamingResponse(api.Features, resp) @@ -82,7 +81,7 @@ func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { func (api *Api) GetAnnotations(c *models.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] if !tokens.IsValidAccessToken(accessToken) { - return response.Error(http.StatusBadRequest, "Invalid Access Token", nil) + return response.Err(ErrInvalidAccessToken.Errorf("GetAnnotations: invalid access token")) } reqDTO := AnnotationsQueryDTO{ @@ -91,9 +90,8 @@ func (api *Api) GetAnnotations(c *models.ReqContext) response.Response { } annotations, err := api.PublicDashboardService.FindAnnotations(c.Req.Context(), reqDTO, accessToken) - if err != nil { - return api.handleError(c.Req.Context(), http.StatusInternalServerError, "error getting public dashboard annotations", err) + return response.Err(err) } return response.JSON(http.StatusOK, annotations) diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index 92b87dec2ed..c5aa5caa787 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -31,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/errutil" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -65,7 +66,7 @@ func TestAPIViewPublicDashboard(t *testing.T) { AccessToken: validAccessToken, ExpectedHttpResponse: http.StatusNotFound, DashboardResult: nil, - Err: ErrPublicDashboardNotFound, + Err: ErrPublicDashboardNotFound.Errorf(""), FixedErrorResponse: "", }, { @@ -74,7 +75,7 @@ func TestAPIViewPublicDashboard(t *testing.T) { ExpectedHttpResponse: http.StatusBadRequest, DashboardResult: nil, Err: nil, - FixedErrorResponse: "{\"message\":\"Invalid Access Token\"}", + FixedErrorResponse: "{\"message\":\"Invalid access token\", \"messageId\":\"publicdashboards.invalidAccessToken\", \"statusCode\":400, \"traceID\":\"\"}", }, } @@ -115,12 +116,13 @@ func TestAPIViewPublicDashboard(t *testing.T) { assert.Equal(t, false, dashResp.Meta.CanSave) } else if test.FixedErrorResponse != "" { require.Equal(t, test.ExpectedHttpResponse, response.Code) - require.JSONEq(t, "{\"message\":\"Invalid Access Token\"}", response.Body.String()) + require.JSONEq(t, "{\"message\":\"Invalid access token\", \"messageId\":\"publicdashboards.invalidAccessToken\", \"statusCode\":400, \"traceID\":\"\"}", response.Body.String()) } else { - var errResp JsonErrResponse + var errResp errutil.PublicError err := json.Unmarshal(response.Body.Bytes(), &errResp) require.NoError(t, err) - assert.Equal(t, test.Err.Error(), errResp.Error) + assert.Equal(t, "Public dashboard not found", errResp.Message) + assert.Equal(t, "publicdashboards.notFound", errResp.MessageID) } }) } @@ -208,19 +210,19 @@ func TestAPIQueryPublicDashboard(t *testing.T) { server, _ := setup(true) resp := callAPI(server, http.MethodPost, getValidQueryPath("SomeInvalidAccessToken"), strings.NewReader("{}"), t) require.Equal(t, http.StatusBadRequest, resp.Code) - require.JSONEq(t, "{\"message\":\"Invalid Access Token\"}", resp.Body.String()) + require.JSONEq(t, "{\"message\":\"Invalid access token\", \"messageId\":\"publicdashboards.invalidAccessToken\", \"statusCode\":400, \"traceID\":\"\"}", resp.Body.String()) }) t.Run("Status code is 400 when the intervalMS is lesser than 0", func(t *testing.T) { server, fakeDashboardService := setup(true) - fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, ErrPublicDashboardBadRequest) + fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, ErrBadRequest.Errorf("")) resp := callAPI(server, http.MethodPost, getValidQueryPath(validAccessToken), strings.NewReader(`{"intervalMs":-100,"maxDataPoints":1000}`), t) require.Equal(t, http.StatusBadRequest, resp.Code) }) t.Run("Status code is 400 when the maxDataPoints is lesser than 0", func(t *testing.T) { server, fakeDashboardService := setup(true) - fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, ErrPublicDashboardBadRequest) + fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, ErrBadRequest.Errorf("")) resp := callAPI(server, http.MethodPost, getValidQueryPath(validAccessToken), strings.NewReader(`{"intervalMs":100,"maxDataPoints":-1000}`), t) require.Equal(t, http.StatusBadRequest, resp.Code) }) diff --git a/pkg/services/publicdashboards/models/errors.go b/pkg/services/publicdashboards/models/errors.go new file mode 100644 index 00000000000..51ff7a1def5 --- /dev/null +++ b/pkg/services/publicdashboards/models/errors.go @@ -0,0 +1,22 @@ +package models + +import "github.com/grafana/grafana/pkg/util/errutil" + +var ( + ErrInternalServerError = errutil.NewBase(errutil.StatusInternal, "publicdashboards.internalServerError", errutil.WithPublicMessage("Internal server error")) + + ErrPublicDashboardNotFound = errutil.NewBase(errutil.StatusNotFound, "publicdashboards.notFound", errutil.WithPublicMessage("Public dashboard not found")) + ErrDashboardNotFound = errutil.NewBase(errutil.StatusNotFound, "publicdashboards.dashboardNotFound", errutil.WithPublicMessage("Dashboard not found")) + ErrPanelNotFound = errutil.NewBase(errutil.StatusNotFound, "publicdashboards.panelNotFound", errutil.WithPublicMessage("Public dashboard panel not found")) + + ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.badRequest") + ErrPanelQueriesNotFound = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.panelQueriesNotFound", errutil.WithPublicMessage("Failed to extract queries from panel")) + ErrInvalidAccessToken = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.invalidAccessToken", errutil.WithPublicMessage("Invalid access token")) + ErrInvalidPanelId = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.invalidPanelId", errutil.WithPublicMessage("Invalid panel id")) + ErrInvalidUid = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.invalidUid", errutil.WithPublicMessage("Invalid Uid")) + + ErrPublicDashboardIdentifierNotSet = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.identifierNotSet", errutil.WithPublicMessage("No Uid for public dashboard specified")) + ErrPublicDashboardHasTemplateVariables = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.hasTemplateVariables", errutil.WithPublicMessage("Public dashboard has template variables")) + ErrInvalidInterval = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.invalidInterval", errutil.WithPublicMessage("intervalMS should be greater than 0")) + ErrInvalidMaxDataPoints = errutil.NewBase(errutil.StatusBadRequest, "publicdashboards.maxDataPoints", errutil.WithPublicMessage("maxDataPoints should be greater than 0")) +) diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index bfdbebe0417..4afa3513856 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -30,43 +30,6 @@ const QueryFailure = "failure" var QueryResultStatuses = []string{QuerySuccess, QueryFailure} -var ( - ErrPublicDashboardFailedGenerateUniqueUid = PublicDashboardErr{ - Reason: "failed to generate unique public dashboard id", - StatusCode: 500, - } - ErrPublicDashboardFailedGenerateAccessToken = PublicDashboardErr{ - Reason: "failed to create public dashboard", - StatusCode: 500, - } - ErrPublicDashboardNotFound = PublicDashboardErr{ - Reason: "public dashboard not found", - StatusCode: 404, - Status: "not-found", - } - ErrPublicDashboardPanelNotFound = PublicDashboardErr{ - Reason: "panel not found in dashboard", - StatusCode: 404, - Status: "not-found", - } - ErrPublicDashboardIdentifierNotSet = PublicDashboardErr{ - Reason: "no Uid for public dashboard specified", - StatusCode: 400, - } - ErrPublicDashboardHasTemplateVariables = PublicDashboardErr{ - Reason: "public dashboard has template variables", - StatusCode: 422, - } - ErrPublicDashboardBadRequest = PublicDashboardErr{ - Reason: "bad Request", - StatusCode: 400, - } - ErrNoPanelQueriesFound = PublicDashboardErr{ - Reason: "failed to extract queries from panel", - StatusCode: 400, - } -) - type PublicDashboard struct { Uid string `json:"uid" xorm:"pk uid"` DashboardUid string `json:"dashboardUid" xorm:"dashboard_uid"` diff --git a/pkg/services/publicdashboards/service/query.go b/pkg/services/publicdashboards/service/query.go index d063660b518..d5c94b86993 100644 --- a/pkg/services/publicdashboards/service/query.go +++ b/pkg/services/publicdashboards/service/query.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/grafanads" ) -// GetAnnotations returns annotations for a public dashboard +// FindAnnotations returns annotations for a public dashboard func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDTO models.AnnotationsQueryDTO, accessToken string) ([]models.AnnotationEvent, error) { pub, dash, err := pd.FindPublicDashboardAndDashboardByAccessToken(ctx, accessToken) if err != nil { @@ -30,7 +30,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT annoDto, err := UnmarshalDashboardAnnotations(dash.Data) if err != nil { - return nil, err + return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to unmarshal dashboard annotations: %w", err) } anonymousUser := buildAnonymousUser(ctx, dash) @@ -59,7 +59,7 @@ func (pd *PublicDashboardServiceImpl) FindAnnotations(ctx context.Context, reqDT annotationItems, err := pd.AnnotationsRepo.Find(ctx, annoQuery) if err != nil { - return nil, err + return nil, models.ErrInternalServerError.Errorf("FindAnnotations: failed to find annotations: %w", err) } for _, item := range annotationItems { @@ -131,7 +131,7 @@ func (pd *PublicDashboardServiceImpl) GetQueryDataResponse(ctx context.Context, } if len(metricReq.Queries) == 0 { - return nil, models.ErrNoPanelQueriesFound + return nil, models.ErrPanelQueriesNotFound.Errorf("GetQueryDataResponse: failed to extract queries from panel") } anonymousUser := buildAnonymousUser(ctx, dashboard) @@ -155,7 +155,7 @@ func (pd *PublicDashboardServiceImpl) buildMetricRequest(ctx context.Context, da queriesByPanel := groupQueriesByPanelId(dashboard.Data) queries, ok := queriesByPanel[panelId] if !ok { - return dtos.MetricRequest{}, models.ErrPublicDashboardPanelNotFound + return dtos.MetricRequest{}, models.ErrPanelNotFound.Errorf("buildMetricRequest: public dashboard panel not found") } ts := publicDashboard.BuildTimeSettings(dashboard) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 366fe40c259..884b82d8d59 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -915,7 +915,7 @@ func TestBuildMetricRequest(t *testing.T) { publicDashboardQueryDTO, ) - require.ErrorContains(t, err, ErrPublicDashboardPanelNotFound.Reason) + require.ErrorContains(t, err, ErrPanelNotFound.Error()) }) t.Run("metric request built without hidden query", func(t *testing.T) { diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 29685c36bc4..daab65439bd 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -65,33 +65,29 @@ func ProvideService( func (pd *PublicDashboardServiceImpl) FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*models.Dashboard, error) { dash, err := pd.store.FindDashboard(ctx, orgId, dashboardUid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("FindDashboard: failed to find dashboard by orgId: %d and dashboardUid: %s: %w", orgId, dashboardUid, err) } if dash == nil { - return nil, dashboards.ErrDashboardNotFound + return nil, ErrDashboardNotFound.Errorf("FindDashboard: dashboard not found by orgId: %d and dashboardUid: %s", orgId, dashboardUid) } return dash, nil } -// FindPublicDashboardAndDashboardByAccessToken Gets public dashboard via access token +// FindPublicDashboardAndDashboardByAccessToken Gets public dashboard and a dashboard by access token func (pd *PublicDashboardServiceImpl) FindPublicDashboardAndDashboardByAccessToken(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error) { - ctxLogger := pd.log.FromContext(ctx) - pubdash, err := pd.store.FindByAccessToken(ctx, accessToken) if err != nil { - return nil, nil, err + return nil, nil, ErrInternalServerError.Errorf("FindPublicDashboardAndDashboardByAccessToken: failed to find a public dashboard: %w", err) } if pubdash == nil { - ctxLogger.Error("FindPublicDashboardAndDashboardByAccessToken: Public dashboard not found", "accessToken", accessToken) - return nil, nil, ErrPublicDashboardNotFound + return nil, nil, ErrPublicDashboardNotFound.Errorf("FindPublicDashboardAndDashboardByAccessToken: Public dashboard not found accessToken: %s", accessToken) } if !pubdash.IsEnabled { - ctxLogger.Error("FindPublicDashboardAndDashboardByAccessToken: Public dashboard is disabled", "accessToken", accessToken) - return nil, nil, ErrPublicDashboardNotFound + return nil, nil, ErrPublicDashboardNotFound.Errorf("FindPublicDashboardAndDashboardByAccessToken: Public dashboard is disabled accessToken: %s", accessToken) } dash, err := pd.store.FindDashboard(ctx, pubdash.OrgId, pubdash.DashboardUid) @@ -100,8 +96,7 @@ func (pd *PublicDashboardServiceImpl) FindPublicDashboardAndDashboardByAccessTok } if dash == nil { - ctxLogger.Error("FindPublicDashboardAndDashboardByAccessToken: Dashboard not found", "accessToken", accessToken) - return nil, nil, ErrPublicDashboardNotFound + return nil, nil, ErrPublicDashboardNotFound.Errorf("FindPublicDashboardAndDashboardByAccessToken: Dashboard not found accessToken: %s", accessToken) } return pubdash, dash, nil @@ -111,11 +106,11 @@ func (pd *PublicDashboardServiceImpl) FindPublicDashboardAndDashboardByAccessTok func (pd *PublicDashboardServiceImpl) FindByDashboardUid(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) { pubdash, err := pd.store.FindByDashboardUid(ctx, orgId, dashboardUid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("FindByDashboardUid: failed to find a public dashboard by orgId: %d and dashboardUid: %s: %w", orgId, dashboardUid, err) } if pubdash == nil { - return nil, ErrPublicDashboardNotFound + return nil, ErrPublicDashboardNotFound.Errorf("FindByDashboardUid: Public dashboard not found by orgId: %d and dashboardUid: %s", orgId, dashboardUid) } return pubdash, nil @@ -144,9 +139,9 @@ func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.Signed // request existingPubdash, err := pd.store.Find(ctx, dto.PublicDashboard.Uid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Create: failed to find the public dashboard: %w", err) } else if existingPubdash != nil { - return nil, ErrPublicDashboardBadRequest + return nil, ErrBadRequest.Errorf("Create: public dashboard already exists: %s", dto.PublicDashboard.Uid) } uid, err := pd.NewPublicDashboardUid(ctx) @@ -175,13 +170,13 @@ func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.Signed _, err = pd.store.Create(ctx, cmd) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Create: failed to create the public dashboard: %w", err) } //Get latest public dashboard to return newPubdash, err := pd.store.Find(ctx, uid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Create: failed to find the public dashboard: %w", err) } pd.logIsEnabledChanged(existingPubdash, newPubdash, u) @@ -189,16 +184,16 @@ func (pd *PublicDashboardServiceImpl) Create(ctx context.Context, u *user.Signed return newPubdash, err } -// Updates an existing public dashboard based on publicdashboard.Uid +// Update: updates an existing public dashboard based on publicdashboard.Uid func (pd *PublicDashboardServiceImpl) Update(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error) { // validate if the dashboard exists dashboard, err := pd.FindDashboard(ctx, u.OrgID, dto.DashboardUid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Update: failed to find dashboard by orgId: %d and dashboardUid: %s: %w", u.OrgID, dto.DashboardUid, err) } if dashboard == nil { - return nil, dashboards.ErrDashboardNotFound + return nil, ErrDashboardNotFound.Errorf("Update: dashboard not found by orgId: %d and dashboardUid: %s", u.OrgID, dto.DashboardUid) } // set default value for time settings @@ -209,9 +204,9 @@ func (pd *PublicDashboardServiceImpl) Update(ctx context.Context, u *user.Signed // get existing public dashboard if exists existingPubdash, err := pd.store.Find(ctx, dto.PublicDashboard.Uid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Update: failed to find public dashboard by uid: %s: %w", dto.PublicDashboard.Uid, err) } else if existingPubdash == nil { - return nil, ErrPublicDashboardNotFound + return nil, ErrPublicDashboardNotFound.Errorf("Update: public dashboard not found by uid: %s", dto.PublicDashboard.Uid) } // validate dashboard @@ -235,23 +230,23 @@ func (pd *PublicDashboardServiceImpl) Update(ctx context.Context, u *user.Signed // persist affectedRows, err := pd.store.Update(ctx, cmd) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Update: failed to update public dashboard: %w", err) } // 404 if not found if affectedRows == 0 { - return nil, ErrPublicDashboardNotFound + return nil, ErrPublicDashboardNotFound.Errorf("Update: failed to update public dashboard not found by uid: %s", dto.PublicDashboard.Uid) } // get latest public dashboard to return newPubdash, err := pd.store.Find(ctx, existingPubdash.Uid) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("Update: failed to find public dashboard by uid: %s: %w", existingPubdash.Uid, err) } pd.logIsEnabledChanged(existingPubdash, newPubdash, u) - return newPubdash, err + return newPubdash, nil } // NewPublicDashboardUid Generates a unique uid to create a public dashboard. Will make 3 attempts and fail if it cannot find an unused uid @@ -265,7 +260,7 @@ func (pd *PublicDashboardServiceImpl) NewPublicDashboardUid(ctx context.Context) return uid, nil } } - return "", ErrPublicDashboardFailedGenerateUniqueUid + return "", ErrInternalServerError.Errorf("failed to generate a unique uid for public dashboard") } // NewPublicDashboardAccessToken Generates a unique accessToken to create a public dashboard. Will make 3 attempts and fail if it cannot find an unused access token @@ -283,14 +278,14 @@ func (pd *PublicDashboardServiceImpl) NewPublicDashboardAccessToken(ctx context. return accessToken, nil } } - return "", ErrPublicDashboardFailedGenerateAccessToken + return "", ErrInternalServerError.Errorf("failed to generate a unique accesssToken for public dashboard") } // FindAll Returns a list of public dashboards by orgId func (pd *PublicDashboardServiceImpl) FindAll(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error) { publicDashboards, err := pd.store.FindAll(ctx, orgId) if err != nil { - return nil, err + return nil, ErrInternalServerError.Errorf("FindAll: %w", err) } return pd.filterDashboardsByPermissions(ctx, u, publicDashboards) @@ -311,11 +306,11 @@ func (pd *PublicDashboardServiceImpl) GetOrgIdByAccessToken(ctx context.Context, func (pd *PublicDashboardServiceImpl) Delete(ctx context.Context, orgId int64, uid string) error { affectedRows, err := pd.store.Delete(ctx, orgId, uid) if err != nil { - return err + return ErrInternalServerError.Errorf("Delete: failed to delete a public dashboard by orgId: %d and Uid: %s %w", orgId, uid, err) } if affectedRows == 0 { - return ErrPublicDashboardNotFound + return ErrPublicDashboardNotFound.Errorf("Delete: Public dashboard not found by orgId: %d and Uid: %s", orgId, uid) } return nil @@ -369,7 +364,7 @@ func (pd *PublicDashboardServiceImpl) filterDashboardsByPermissions(ctx context. hasAccess, err := pd.ac.Evaluate(ctx, u, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(publicDashboards[i].DashboardUid))) // If original dashboard does not exist, the public dashboard is an orphan. We want to list it anyway if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { - return nil, err + return nil, ErrInternalServerError.Errorf("filterDashboardsByPermissions: error evaluating permissions %w", err) } // If user has access to the original dashboard or the dashboard does not exist, add the pubdash to the result diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 82dec54da3d..caafbad220e 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -258,9 +258,8 @@ func TestCreatePublicDashboard(t *testing.T) { } _, err := service.Create(context.Background(), SignedInUser, dto) - require.Error(t, err) - require.Equal(t, err, ErrPublicDashboardFailedGenerateAccessToken) + require.Equal(t, err, ErrInternalServerError.Errorf("failed to generate a unique accesssToken for public dashboard")) publicDashboardStore.AssertNotCalled(t, "Create") }) @@ -309,7 +308,8 @@ func TestCreatePublicDashboard(t *testing.T) { } _, err = service.Create(context.Background(), SignedInUser, dto) - assert.Equal(t, ErrPublicDashboardBadRequest, err) + require.Error(t, err) + assert.True(t, ErrBadRequest.Is(err)) }) } @@ -428,33 +428,33 @@ func TestDeletePublicDashboard(t *testing.T) { testCases := []struct { Name string AffectedRowsResp int64 - ErrResp error - ExpectedErr error + ExpectedErrResp error + StoreRespErr error }{ { Name: "Successfully deletes a public dashboards", AffectedRowsResp: 1, - ErrResp: nil, - ExpectedErr: nil, + ExpectedErrResp: nil, + StoreRespErr: nil, }, { Name: "Public dashboard not found", AffectedRowsResp: 0, - ErrResp: nil, - ExpectedErr: ErrPublicDashboardNotFound, + ExpectedErrResp: ErrPublicDashboardNotFound.Errorf("Delete: Public dashboard not found by orgId: 13 and Uid: uid"), + StoreRespErr: nil, }, { Name: "Database error", AffectedRowsResp: 0, - ErrResp: errors.New("db error!"), - ExpectedErr: errors.New("db error!"), + ExpectedErrResp: ErrInternalServerError.Errorf("Delete: failed to delete a public dashboard by orgId: 13 and Uid: uid db error!"), + StoreRespErr: errors.New("db error!"), }, } for _, tt := range testCases { t.Run(tt.Name, func(t *testing.T) { store := NewFakePublicDashboardStore(t) - store.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.AffectedRowsResp, tt.ExpectedErr) + store.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.AffectedRowsResp, tt.StoreRespErr) service := &PublicDashboardServiceImpl{ log: log.New("test.logger"), @@ -462,7 +462,12 @@ func TestDeletePublicDashboard(t *testing.T) { } err := service.Delete(context.Background(), 13, "uid") - assert.Equal(t, tt.ExpectedErr, err) + if tt.ExpectedErrResp != nil { + assert.Equal(t, tt.ExpectedErrResp.Error(), err.Error()) + assert.Equal(t, tt.ExpectedErrResp.Error(), err.Error()) + } else { + assert.NoError(t, err) + } }) } } @@ -833,7 +838,7 @@ func TestPublicDashboardServiceImpl_NewPublicDashboardUid(t *testing.T) { store.AssertNumberOfCalls(t, "Find", 1) } else { store.AssertNumberOfCalls(t, "Find", 3) - assert.True(t, errors.Is(err, ErrPublicDashboardFailedGenerateUniqueUid)) + assert.True(t, ErrInternalServerError.Is(err)) } }) } @@ -897,7 +902,7 @@ func TestPublicDashboardServiceImpl_NewPublicDashboardAccessToken(t *testing.T) store.AssertNumberOfCalls(t, "FindByAccessToken", 1) } else { store.AssertNumberOfCalls(t, "FindByAccessToken", 3) - assert.True(t, errors.Is(err, ErrPublicDashboardFailedGenerateAccessToken)) + assert.True(t, ErrInternalServerError.Is(err)) } }) } diff --git a/pkg/services/publicdashboards/validation/validation.go b/pkg/services/publicdashboards/validation/validation.go index d0b4936625e..727e2371580 100644 --- a/pkg/services/publicdashboards/validation/validation.go +++ b/pkg/services/publicdashboards/validation/validation.go @@ -1,15 +1,13 @@ package validation import ( - "fmt" - "github.com/grafana/grafana/pkg/models" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" ) func ValidatePublicDashboard(dto *SavePublicDashboardDTO, dashboard *models.Dashboard) error { if hasTemplateVariables(dashboard) { - return ErrPublicDashboardHasTemplateVariables + return ErrPublicDashboardHasTemplateVariables.Errorf("ValidateSavePublicDashboard: public dashboard has template variables") } return nil @@ -23,11 +21,11 @@ func hasTemplateVariables(dashboard *models.Dashboard) bool { func ValidateQueryPublicDashboardRequest(req PublicDashboardQueryDTO) error { if req.IntervalMs < 0 { - return fmt.Errorf("intervalMS should be greater than 0") + return ErrInvalidInterval.Errorf("ValidateQueryPublicDashboardRequest: intervalMS should be greater than 0") } if req.MaxDataPoints < 0 { - return fmt.Errorf("maxDataPoints should be greater than 0") + return ErrInvalidMaxDataPoints.Errorf("ValidateQueryPublicDashboardRequest: maxDataPoints should be greater than 0") } return nil diff --git a/pkg/services/publicdashboards/validation/validation_test.go b/pkg/services/publicdashboards/validation/validation_test.go index 1273ca3191f..dbae8acc7db 100644 --- a/pkg/services/publicdashboards/validation/validation_test.go +++ b/pkg/services/publicdashboards/validation/validation_test.go @@ -25,7 +25,7 @@ func TestValidatePublicDashboard(t *testing.T) { dto := &SavePublicDashboardDTO{DashboardUid: "abc123", OrgId: 1, UserId: 1, PublicDashboard: nil} err := ValidatePublicDashboard(dto, dashboard) - require.ErrorContains(t, err, ErrPublicDashboardHasTemplateVariables.Reason) + require.ErrorContains(t, err, ErrPublicDashboardHasTemplateVariables.Error()) }) t.Run("Returns no validation error when dashboard has no template variables", func(t *testing.T) { From 8f6cdd4cda702cf4e161dd8dd16e26f5a9bb41fc Mon Sep 17 00:00:00 2001 From: juanicabanas Date: Fri, 4 Nov 2022 15:08:50 -0300 Subject: [PATCH 060/926] PublicDashboards: Add delete public dashboard button in public dashboard modal (#58095) - Delete public dashboard button added in public dashboard modal - Delete public dashboard button refactored in order to be used in audit table and public dashboard modal - Tests added - RTK Query api modified, in order to keep cached data because of having to show public dashboard modal once delete modal is closed. - RTK Query specific cached data invalidated for public dashboard - Save button text changed: Create public dashboard when it was never created. Save public dashboard when there's a public dashboard already created - Public Dashboard modal subscribed to DashboardModel metadata changes --- .../src/selectors/pages.ts | 1 + .../dashboard/api/publicDashboardApi.ts | 34 +++--- .../SharePublicDashboard.test.tsx | 61 ++++++++-- .../SharePublicDashboard.tsx | 112 +++++++++++++----- .../DeletePublicDashboardButton.tsx | 49 +++++--- .../DeletePublicDashboardModal.tsx | 2 +- .../PublicDashboardListTable.tsx | 13 +- 7 files changed, 195 insertions(+), 77 deletions(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 9b968cb284d..4bdc8923d45 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -188,6 +188,7 @@ export const Pages = { EnableSwitch: 'data-testid public dashboard on off switch', EnableAnnotationsSwitch: 'data-testid public dashboard on off switch for annotations', SaveConfigButton: 'data-testid public dashboard save config button', + DeleteButton: 'data-testid public dashboard delete button', CopyUrlInput: 'data-testid public dashboard copy url input', CopyUrlButton: 'data-testid public dashboard copy url button', TemplateVariablesWarningAlert: 'data-testid public dashboard disabled template variables alert', diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index 460739f7581..4249547a967 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -36,7 +36,7 @@ export const publicDashboardApi = createApi({ reducerPath: 'publicDashboardApi', baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 0 }), tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard'], - keepUnusedDataFor: 0, + refetchOnMountOrArgChange: true, endpoints: (builder) => ({ getPublicDashboard: builder.query({ query: (dashboardUid) => ({ @@ -53,7 +53,7 @@ export const publicDashboardApi = createApi({ dispatch(notifyApp(createErrorNotification(customError?.error?.data?.message))); } }, - providesTags: ['PublicDashboard'], + providesTags: (result, error, dashboardUid) => [{ type: 'PublicDashboard', id: dashboardUid }], }), createPublicDashboard: builder.mutation({ query: (params) => ({ @@ -72,7 +72,7 @@ export const publicDashboardApi = createApi({ publicDashboardEnabled: data.isEnabled, }); }, - invalidatesTags: ['PublicDashboard'], + invalidatesTags: (result, error, { payload }) => [{ type: 'PublicDashboard', id: payload.dashboardUid }], }), updatePublicDashboard: builder.mutation({ query: (params) => ({ @@ -92,7 +92,7 @@ export const publicDashboardApi = createApi({ publicDashboardEnabled: data.isEnabled, }); }, - invalidatesTags: ['PublicDashboard'], + invalidatesTags: (result, error, { payload }) => [{ type: 'PublicDashboard', id: payload.dashboardUid }], }), listPublicDashboards: builder.query({ query: () => ({ @@ -100,25 +100,25 @@ export const publicDashboardApi = createApi({ }), providesTags: ['AuditTablePublicDashboard'], }), - deletePublicDashboard: builder.mutation({ + deletePublicDashboard: builder.mutation({ query: (params) => ({ url: `/uid/${params.dashboardUid}/public-dashboards/${params.uid}`, method: 'DELETE', }), - async onQueryStarted({ dashboardTitle }, { dispatch, queryFulfilled }) { + async onQueryStarted({ dashboard, uid }, { dispatch, queryFulfilled }) { await queryFulfilled; - dispatch( - notifyApp( - createSuccessNotification( - 'Public dashboard deleted', - !!dashboardTitle - ? `Public dashboard for ${dashboardTitle} has been deleted` - : `Public dashboard has been deleted` - ) - ) - ); + dispatch(notifyApp(createSuccessNotification('Public dashboard deleted!'))); + + dashboard?.updateMeta({ + hasPublicDashboard: false, + publicDashboardUid: uid, + publicDashboardEnabled: false, + }); }, - invalidatesTags: ['AuditTablePublicDashboard'], + invalidatesTags: (result, error, { dashboardUid }) => [ + { type: 'PublicDashboard', id: dashboardUid }, + 'AuditTablePublicDashboard', + ], }), }), }); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index 15cdbec7a7d..fa0eb33020e 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -100,7 +100,6 @@ describe('SharePublic', () => { expect(screen.getByRole('tablist')).toHaveTextContent('Link'); expect(screen.getByRole('tablist')).not.toHaveTextContent('Public dashboard'); }); - it('renders share panel when public dashboards feature is enabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); @@ -110,8 +109,23 @@ describe('SharePublic', () => { fireEvent.click(screen.getByText('Public dashboard')); await screen.findByText('Welcome to Grafana public dashboards alpha!'); + expect(screen.getByText('Create public dashboard')).toBeInTheDocument(); + expect(screen.queryByTestId(selectors.DeleteButton)).not.toBeInTheDocument(); }); + it('renders public dashboard modal without delete button because no public dashboard was already created', async () => { + jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false); + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + expect(screen.getByRole('tablist')).toHaveTextContent('Link'); + expect(screen.getByRole('tablist')).toHaveTextContent('Public dashboard'); + + fireEvent.click(screen.getByText('Public dashboard')); + + await screen.findByText('Welcome to Grafana public dashboards alpha!'); + + expect(screen.getByText('Create public dashboard')).toBeInTheDocument(); + expect(screen.queryByTestId(selectors.DeleteButton)).not.toBeInTheDocument(); + }); it('renders default relative time in input', async () => { expect(mockDashboard.time).toEqual({ from: 'now-6h', to: 'now' }); @@ -137,13 +151,15 @@ describe('SharePublic', () => { mockDashboard.meta.hasPublicDashboard = true; await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + screen.getAllByTestId('Spinner'); + expect(screen.getByText('Save public dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.EnableSwitch)).toBeDisabled(); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + expect(screen.queryByTestId(selectors.DeleteButton)).not.toBeInTheDocument(); }); it('when fetch errors happen, then all inputs remain disabled', async () => { mockDashboard.meta.hasPublicDashboard = true; @@ -154,14 +170,16 @@ describe('SharePublic', () => { ); await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.EnableSwitch)).toBeDisabled(); expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeDisabled(); + expect(screen.getByText('Save public dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + expect(screen.queryByTestId(selectors.DeleteButton)).not.toBeInTheDocument(); }); // test checking if current version of dashboard in state is persisted to db }); @@ -183,7 +201,9 @@ describe('SharePublic - New config setup', () => { expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeEnabled(); expect(screen.getByTestId(selectors.EnableSwitch)).toBeEnabled(); expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeEnabled(); + expect(screen.queryByTestId(selectors.DeleteButton)).not.toBeInTheDocument(); + expect(screen.getByText('Create public dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); }); it('when checkboxes are filled, then save button remains disabled', async () => { @@ -194,6 +214,7 @@ describe('SharePublic - New config setup', () => { fireEvent.click(screen.getByTestId(selectors.LimitedDSCheckbox)); fireEvent.click(screen.getByTestId(selectors.CostIncreaseCheckbox)); + expect(screen.getByText('Create public dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); }); it('when checkboxes and switch are filled, then save button is enabled', async () => { @@ -207,6 +228,10 @@ describe('SharePublic - New config setup', () => { expect(screen.getByTestId(selectors.SaveConfigButton)).toBeEnabled(); }); + it('when hasPublicDashboard flag is false, then button text is Create', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + expect(screen.getByText('Create public dashboard')).toBeInTheDocument(); + }); }); describe('SharePublic - Already persisted', () => { @@ -228,33 +253,44 @@ describe('SharePublic - Already persisted', () => { ); }); - it('when modal is opened, then save button is enabled', async () => { + it('when modal is opened, then save button and delete button are enabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + expect(screen.getByTestId(selectors.DeleteButton)).toBeEnabled(); + expect(screen.getByText('Save public dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeEnabled(); }); + it('delete button is not rendered because lack of permissions', async () => { + jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false); + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + expect(screen.queryByTestId(selectors.DeleteButton)).not.toBeInTheDocument(); + }); it('when modal is opened, then annotations toggle is enabled and checked when its enabled in the db', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeEnabled(); expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).toBeChecked(); }); it('when fetch is done, then loader spinner is gone, inputs are disabled and save button is enabled', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeDisabled(); expect(screen.getByTestId(selectors.EnableSwitch)).toBeEnabled(); + expect(screen.getByText('Save public dashboard')).toBeInTheDocument(); expect(screen.getByTestId(selectors.SaveConfigButton)).toBeEnabled(); + expect(screen.getByTestId(selectors.DeleteButton)).toBeEnabled(); }); it('when pubdash is enabled, then link url is available', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); expect(screen.getByTestId(selectors.CopyUrlInput)).toBeInTheDocument(); }); it('when pubdash is disabled in the db, then link url is not available and annotations toggle is disabled', async () => { @@ -274,16 +310,21 @@ describe('SharePublic - Already persisted', () => { ); await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); expect(screen.queryByTestId(selectors.CopyUrlInput)).not.toBeInTheDocument(); expect(screen.getByTestId(selectors.EnableAnnotationsSwitch)).not.toBeChecked(); }); it('when pubdash is disabled by the user, then link url is not available', async () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); - await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); fireEvent.click(screen.getByTestId(selectors.EnableSwitch)); expect(screen.queryByTestId(selectors.CopyUrlInput)).not.toBeInTheDocument(); }); + it('when hasPublicDashboard flag is true, then button text is Save', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + expect(screen.getByText('Save public dashboard')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx index 4af0c3d7ff8..cb5972ba1c3 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx @@ -1,10 +1,23 @@ import { css } from '@emotion/css'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { Subscription } from 'rxjs'; import { GrafanaTheme2 } from '@grafana/data/src'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; import { reportInteraction } from '@grafana/runtime/src'; -import { Alert, Button, ClipboardButton, Field, HorizontalGroup, Input, useStyles2, Spinner } from '@grafana/ui/src'; +import { + Alert, + Button, + ClipboardButton, + Field, + HorizontalGroup, + Input, + useStyles2, + Spinner, + ModalsContext, + useForceUpdate, +} from '@grafana/ui/src'; +import { Layout } from '@grafana/ui/src/components/Layout/Layout'; import { contextSrv } from 'app/core/services/context_srv'; import { useGetPublicDashboardQuery, @@ -21,22 +34,31 @@ import { publicDashboardPersisted, } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; import { ShareModalTabProps } from 'app/features/dashboard/components/ShareModal/types'; +import { useIsDesktop } from 'app/features/dashboard/utils/screen'; +import { DeletePublicDashboardButton } from 'app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton'; import { isOrgAdmin } from 'app/features/plugins/admin/permissions'; import { AccessControlAction } from 'app/types'; +import { DashboardMetaChangedEvent } from '../../../../../types/events'; +import { ShareModal } from '../ShareModal'; + interface Props extends ShareModalTabProps {} export const SharePublicDashboard = (props: Props) => { + const forceUpdate = useForceUpdate(); + const styles = useStyles2(getStyles); + const { showModal, hideModal } = useContext(ModalsContext); + const isDesktop = useIsDesktop(); + const dashboardVariables = props.dashboard.getVariables(); const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; - const styles = useStyles2(getStyles); - - const [hasPublicDashboard, setHasPublicDashboard] = useState(props.dashboard.meta.hasPublicDashboard); + const { hasPublicDashboard } = props.dashboard.meta; const { - isLoading: isFetchingLoading, + isLoading: isGetLoading, data: publicDashboard, - isError: isFetchingError, + isError: isGetError, + isFetching, } = useGetPublicDashboardQuery(props.dashboard.uid, { // if we don't have a public dashboard, don't try to load public dashboard skip: !hasPublicDashboard, @@ -57,8 +79,12 @@ export const SharePublicDashboard = (props: Props) => { const [annotationsEnabled, setAnnotationsEnabled] = useState(false); useEffect(() => { + const eventSubs = new Subscription(); + eventSubs.add(props.dashboard.events.subscribe(DashboardMetaChangedEvent, forceUpdate)); reportInteraction('grafana_dashboards_public_share_viewed'); - }, []); + + return () => eventSubs.unsubscribe(); + }, [props.dashboard.events, forceUpdate]); useEffect(() => { if (publicDashboardPersisted(publicDashboard)) { @@ -73,19 +99,30 @@ export const SharePublicDashboard = (props: Props) => { setEnabledSwitch((prevState) => ({ ...prevState, isEnabled: !!publicDashboard?.isEnabled })); }, [publicDashboard]); - const isLoading = isFetchingLoading || isSaveLoading || isUpdateLoading; + const isLoading = isGetLoading || isSaveLoading || isUpdateLoading; const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin()); const acknowledged = acknowledgements.public && acknowledgements.datasources && acknowledgements.usage; - const isSaveEnabled = useMemo( + const isSaveDisabled = useMemo( () => !hasWritePermissions || !acknowledged || props.dashboard.hasUnsavedChanges() || isLoading || - isFetchingError || + isFetching || + isGetError || (!publicDashboardPersisted(publicDashboard) && !enabledSwitch.wasTouched), - [hasWritePermissions, acknowledged, props.dashboard, isLoading, isFetchingError, enabledSwitch, publicDashboard] + [ + hasWritePermissions, + acknowledged, + props.dashboard, + isLoading, + isGetError, + enabledSwitch, + publicDashboard, + isFetching, + ] ); + const isDeleteDisabled = isLoading || isFetching || isGetError; const onSavePublicConfig = async () => { reportInteraction('grafana_dashboards_public_create_clicked'); @@ -96,20 +133,21 @@ export const SharePublicDashboard = (props: Props) => { }; // create or update based on whether we have existing uid - - if (hasPublicDashboard) { - await updatePublicDashboard(req).unwrap(); - setHasPublicDashboard(true); - } else { - await createPublicDashboard(req).unwrap(); - setHasPublicDashboard(true); - } + hasPublicDashboard ? updatePublicDashboard(req) : createPublicDashboard(req); }; const onAcknowledge = (field: string, checked: boolean) => { setAcknowledgements((prevState) => ({ ...prevState, [field]: checked })); }; + const onDismissDelete = () => { + showModal(ShareModal, { + dashboard: props.dashboard, + onDismiss: hideModal, + activeTab: 'share', + }); + }; + return ( <> @@ -120,7 +158,7 @@ export const SharePublicDashboard = (props: Props) => { > Welcome to Grafana public dashboards alpha!

- {isFetchingLoading && } + {(isGetLoading || isFetching) && }
{dashboardHasTemplateVariables(dashboardVariables) && !publicDashboardPersisted(publicDashboard) ? ( @@ -137,9 +175,7 @@ export const SharePublicDashboard = (props: Props) => {
@@ -148,7 +184,7 @@ export const SharePublicDashboard = (props: Props) => { setEnabledSwitch((prevState) => ({ isEnabled: !prevState.isEnabled, wasTouched: true })) @@ -193,10 +229,28 @@ export const SharePublicDashboard = (props: Props) => { )} - - {isSaveLoading && } + + + {publicDashboard && hasWritePermissions && ( + + Delete public dashboard + + )} + + {(isSaveLoading || isFetching) && } )} diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx index 837098f1a4c..125aebe4d3e 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx @@ -1,47 +1,60 @@ import React from 'react'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; -import { Button, ComponentSize, Icon, ModalsController, Spinner } from '@grafana/ui/src'; - -import { useDeletePublicDashboardMutation } from '../../../dashboard/api/publicDashboardApi'; -import { ListPublicDashboardResponse } from '../../types'; +import { Button, ModalsController, ButtonProps } from '@grafana/ui/src'; +import { useDeletePublicDashboardMutation } from 'app/features/dashboard/api/publicDashboardApi'; +import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DeletePublicDashboardModal } from './DeletePublicDashboardModal'; +export interface PublicDashboardDeletion { + uid: string; + dashboardUid: string; + title: string; +} + export const DeletePublicDashboardButton = ({ + dashboard, publicDashboard, - size, + loader, + children, + onDismiss, + ...rest }: { - publicDashboard: ListPublicDashboardResponse; - size: ComponentSize; -}) => { + dashboard?: DashboardModel; + publicDashboard: PublicDashboardDeletion; + loader?: JSX.Element; + children: React.ReactNode; + onDismiss?: () => void; +} & ButtonProps) => { const [deletePublicDashboard, { isLoading }] = useDeletePublicDashboardMutation(); - const onDeletePublicDashboardClick = (pd: ListPublicDashboardResponse, onDelete: () => void) => { - deletePublicDashboard({ uid: pd.uid, dashboardUid: pd.dashboardUid, dashboardTitle: pd.title }); + const onDeletePublicDashboardClick = (pd: PublicDashboardDeletion, onDelete: () => void) => { + deletePublicDashboard({ + dashboard, + uid: pd.uid, + dashboardUid: pd.dashboardUid, + }); onDelete(); }; - const selectors = e2eSelectors.pages.PublicDashboards; - return ( {({ showModal, hideModal }) => ( )} diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx index aa5dacc86e1..bd2ef614546 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx @@ -12,7 +12,7 @@ const Body = ({ title }: { title?: string }) => {

Do you want to delete this public dashboard?

{title - ? `This will delete the public dashboard for ${title}. Your dashboard will not be deleted.` + ? `This will delete the public dashboard for "${title}". Your dashboard will not be deleted.` : 'Orphaned public dashboard will be deleted'}

diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx index 2b624e53a1d..bd5416f730a 100644 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx @@ -94,7 +94,15 @@ export const PublicDashboardListTable = () => { {hasWritePermissions && ( - + } + > + + )} @@ -134,9 +142,10 @@ function getStyles(theme: GrafanaTheme2, isMobile: boolean) { orphanedTitle: css` display: flex; align-items: center; + gap: ${theme.spacing(1)}; p { - margin: ${theme.spacing(0, 1, 0, 0)}; + margin: ${theme.spacing(0)}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From dce887914596e916bf04220611dee1217ee84682 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 4 Nov 2022 14:23:08 -0400 Subject: [PATCH 061/926] Alerting: Update state manager to accept rule store as Warm method argument (#58244) --- pkg/services/ngalert/ngalert.go | 6 ++++-- pkg/services/ngalert/schedule/schedule_test.go | 6 +++--- .../ngalert/schedule/schedule_unit_test.go | 3 +-- pkg/services/ngalert/state/manager.go | 13 ++++--------- pkg/services/ngalert/state/manager_test.go | 14 +++++++------- 5 files changed, 19 insertions(+), 23 deletions(-) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index d44b1dc46b6..b1e23e36420 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -127,6 +127,7 @@ type AlertNG struct { accesscontrol accesscontrol.AccessControl accesscontrolService accesscontrol.Service annotationsRepo annotations.Repository + store *store.DBstore bus bus.Bus } @@ -143,6 +144,7 @@ func (ng *AlertNG) init() error { AccessControl: ng.accesscontrol, DashboardService: ng.dashboardService, } + ng.store = store decryptFn := ng.SecretsService.GetDecryptedValue multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics() @@ -191,7 +193,7 @@ func (ng *AlertNG) init() error { } historian := historian.NewAnnotationHistorian(ng.annotationsRepo, ng.dashboardService) - stateManager := state.NewManager(ng.Metrics.GetStateMetrics(), appUrl, store, store, ng.imageService, clk, historian) + stateManager := state.NewManager(ng.Metrics.GetStateMetrics(), appUrl, store, ng.imageService, clk, historian) scheduler := schedule.NewScheduler(schedCfg, appUrl, stateManager) // if it is required to include folder title to the alerts, we need to subscribe to changes of alert title @@ -276,7 +278,7 @@ func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleSt // Run starts the scheduler and Alertmanager. func (ng *AlertNG) Run(ctx context.Context) error { ng.Log.Debug("Starting") - ng.stateManager.Warm(ctx) + ng.stateManager.Warm(ctx, ng.store) children, subCtx := errgroup.WithContext(ctx) diff --git a/pkg/services/ngalert/schedule/schedule_test.go b/pkg/services/ngalert/schedule/schedule_test.go index 25e7311a640..f741db6aa3b 100644 --- a/pkg/services/ngalert/schedule/schedule_test.go +++ b/pkg/services/ngalert/schedule/schedule_test.go @@ -106,8 +106,8 @@ func TestWarmStateCache(t *testing.T) { Labels: labels, } _ = dbstore.SaveAlertInstances(ctx, instance2) - st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, dbstore, &image.NoopImageService{}, clock.NewMock(), &state.FakeHistorian{}) - st.Warm(ctx) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, &image.NoopImageService{}, clock.NewMock(), &state.FakeHistorian{}) + st.Warm(ctx, dbstore) t.Run("instance cache has expected entries", func(t *testing.T) { for _, entry := range expectedEntries { @@ -157,7 +157,7 @@ func TestAlertingTicker(t *testing.T) { Metrics: testMetrics.GetSchedulerMetrics(), AlertSender: notifier, } - st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, dbstore, &image.NoopImageService{}, clock.NewMock(), &state.FakeHistorian{}) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, &image.NoopImageService{}, clock.NewMock(), &state.FakeHistorian{}) appUrl := &url.URL{ Scheme: "http", Host: "localhost", diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index e30e53e2490..00dfdc475b4 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -524,8 +524,7 @@ func setupScheduler(t *testing.T, rs *fakeRulesStore, is *state.FakeInstanceStor AlertSender: senderMock, } - stateRs := state.FakeRuleReader{} - st := state.NewManager(m.GetStateMetrics(), nil, &stateRs, is, &image.NoopImageService{}, mockedClock, &state.FakeHistorian{}) + st := state.NewManager(m.GetStateMetrics(), nil, is, &image.NoopImageService{}, mockedClock, &state.FakeHistorian{}) return NewScheduler(schedCfg, appUrl, st) } diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 89d878a0e3a..ad27bb68278 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -32,22 +32,19 @@ type Manager struct { quit chan struct{} ResendDelay time.Duration - ruleStore RuleReader instanceStore InstanceStore imageService image.ImageService historian Historian externalURL *url.URL } -func NewManager(metrics *metrics.State, externalURL *url.URL, - ruleStore RuleReader, instanceStore InstanceStore, imageService image.ImageService, clock clock.Clock, historian Historian) *Manager { +func NewManager(metrics *metrics.State, externalURL *url.URL, instanceStore InstanceStore, imageService image.ImageService, clock clock.Clock, historian Historian) *Manager { manager := &Manager{ cache: newCache(), quit: make(chan struct{}), ResendDelay: ResendDelay, // TODO: make this configurable log: log.New("ngalert.state.manager"), metrics: metrics, - ruleStore: ruleStore, instanceStore: instanceStore, imageService: imageService, historian: historian, @@ -64,12 +61,10 @@ func (st *Manager) Close() { st.quit <- struct{}{} } -func (st *Manager) Warm(ctx context.Context) { +func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader) { if st.instanceStore == nil { st.log.Info("Skip warming the state because instance store is not configured") - } - if st.ruleStore == nil { - st.log.Info("Skip warming the state because rule store is not configured") + return } startTime := time.Now() st.log.Info("Warming state cache for startup") @@ -86,7 +81,7 @@ func (st *Manager) Warm(ctx context.Context) { ruleCmd := ngModels.ListAlertRulesQuery{ OrgID: orgId, } - if err := st.ruleStore.ListAlertRules(ctx, &ruleCmd); err != nil { + if err := rulesReader.ListAlertRules(ctx, &ruleCmd); err != nil { st.log.Error("Unable to fetch previous state", "error", err) } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 176fcd75111..58a9497fc70 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -39,7 +39,7 @@ func TestDashboardAnnotations(t *testing.T) { fakeAnnoRepo := annotationstest.NewFakeAnnotationsRepo() hist := historian.NewAnnotationHistorian(fakeAnnoRepo, &dashboards.FakeDashboardService{}) - st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, dbstore, &image.NoopImageService{}, clock.New(), hist) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, &image.NoopImageService{}, clock.New(), hist) const mainOrgID int64 = 1 @@ -48,7 +48,7 @@ func TestDashboardAnnotations(t *testing.T) { "test2": "{{ $labels.instance_label }}", }) - st.Warm(ctx) + st.Warm(ctx, dbstore) bValue := float64(42) cValue := float64(1) _ = st.ProcessEvalResults(ctx, evaluationTime, rule, eval.Results{{ @@ -2020,7 +2020,7 @@ func TestProcessEvalResults(t *testing.T) { for _, tc := range testCases { fakeAnnoRepo := annotationstest.NewFakeAnnotationsRepo() hist := historian.NewAnnotationHistorian(fakeAnnoRepo, &dashboards.FakeDashboardService{}) - st := state.NewManager(testMetrics.GetStateMetrics(), nil, nil, &state.FakeInstanceStore{}, &image.NotAvailableImageService{}, clock.New(), hist) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, &state.FakeInstanceStore{}, &image.NotAvailableImageService{}, clock.New(), hist) t.Run(tc.desc, func(t *testing.T) { for _, res := range tc.evalResults { _ = st.ProcessEvalResults(context.Background(), evaluationTime, tc.alertRule, res, data.Labels{ @@ -2047,7 +2047,7 @@ func TestProcessEvalResults(t *testing.T) { t.Run("should save state to database", func(t *testing.T) { instanceStore := &state.FakeInstanceStore{} clk := clock.New() - st := state.NewManager(testMetrics.GetStateMetrics(), nil, nil, instanceStore, &image.NotAvailableImageService{}, clk, &state.FakeHistorian{}) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, instanceStore, &image.NotAvailableImageService{}, clk, &state.FakeHistorian{}) rule := models.AlertRuleGen()() var results = eval.GenerateResults(rand.Intn(4)+1, eval.ResultGen(eval.WithEvaluatedAt(clk.Now()))) @@ -2176,8 +2176,8 @@ func TestStaleResultsHandler(t *testing.T) { for _, tc := range testCases { ctx := context.Background() - st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, dbstore, &image.NoopImageService{}, clock.New(), &state.FakeHistorian{}) - st.Warm(ctx) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, &image.NoopImageService{}, clock.New(), &state.FakeHistorian{}) + st.Warm(ctx, dbstore) existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID) // We have loaded the expected number of entries from the db @@ -2238,7 +2238,7 @@ func TestStaleResults(t *testing.T) { clk := clock.NewMock() clk.Set(time.Now()) - st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, dbstore, &image.NoopImageService{}, clk, &state.FakeHistorian{}) + st := state.NewManager(testMetrics.GetStateMetrics(), nil, dbstore, &image.NoopImageService{}, clk, &state.FakeHistorian{}) orgID := rand.Int63() rule := tests.CreateTestAlertRule(t, ctx, dbstore, 10, orgID) From d80abd173bf67157c315401e8bac59cb3928f56b Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri, 4 Nov 2022 15:14:56 -0400 Subject: [PATCH 062/926] Chore: Sort generated jsonnet dashboards by full path (#58267) --- devenv/dev-dashboards/gen.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv/dev-dashboards/gen.go b/devenv/dev-dashboards/gen.go index df61ba3c89d..b785bbd6886 100644 --- a/devenv/dev-dashboards/gen.go +++ b/devenv/dev-dashboards/gen.go @@ -76,7 +76,7 @@ func (g *libjsonnetGen) generate() (string, error) { return "", err } - sort.Slice(g.dashboards, func(i, j int) bool { + sort.SliceStable(g.dashboards, func(i, j int) bool { return g.dashboards[i].Name < g.dashboards[j].Name }) From e6a9fa1cf91e408a5d2fa287f1a35e6b3f3c191b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 4 Nov 2022 12:53:35 -0700 Subject: [PATCH 063/926] ServiceAccounts: enable service accounts after IsRealUser change (#58263) * suppor service accounts * add: IsServiceAccount to scheduleUser in scheduler Co-authored-by: eleijonmarck --- .../accesscontrol/acimpl/service_test.go | 66 +++++++++++++++++++ pkg/services/ngalert/schedule/schedule.go | 10 +-- pkg/services/user/model.go | 3 + 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index b7882c05251..c18b63cc290 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -239,3 +240,68 @@ func TestService_RegisterFixedRoles(t *testing.T) { }) } } + +func TestPermissionCacheKey(t *testing.T) { + testcases := []struct { + name string + signedInUser *user.SignedInUser + expected string + expectedErr error + }{ + { + name: "should return correct key for user", + signedInUser: &user.SignedInUser{ + OrgID: 1, + UserID: 1, + }, + expected: "rbac-permissions-1-user-1", + expectedErr: nil, + }, + { + name: "should return correct key for api key", + signedInUser: &user.SignedInUser{ + OrgID: 1, + ApiKeyID: 1, + IsServiceAccount: false, + }, + expected: "rbac-permissions-1-apikey-1", + expectedErr: nil, + }, + { + name: "should return correct key for service account", + signedInUser: &user.SignedInUser{ + OrgID: 1, + UserID: 1, + IsServiceAccount: true, + }, + expected: "rbac-permissions-1-service-1", + expectedErr: nil, + }, + { + name: "should return correct key for matching a service account with userId -1", + signedInUser: &user.SignedInUser{ + OrgID: 1, + UserID: -1, + IsServiceAccount: true, + }, + expected: "rbac-permissions-1-service--1", + expectedErr: nil, + }, + { + name: "should return error if not matching any", + signedInUser: &user.SignedInUser{ + OrgID: 1, + UserID: -1, + }, + expected: "", + expectedErr: user.ErrNoUniqueID, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + str, err := permissionCacheKey(tc.signedInUser) + require.Equal(t, tc.expectedErr, err) + assert.Equal(t, tc.expected, str) + }) + } +} diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index a072b2dae2d..ea9a3e5eb1c 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -328,11 +328,11 @@ func (sch *schedule) ruleRoutine(grafanaCtx context.Context, key ngmodels.AlertR start := sch.clock.Now() schedulerUser := &user.SignedInUser{ - // FIXME: add is service account and refactor to a service account instead of a user - UserID: -1, - Login: "grafana_scheduler", - OrgID: e.rule.OrgID, - OrgRole: org.RoleAdmin, + UserID: -1, + IsServiceAccount: true, + Login: "grafana_scheduler", + OrgID: e.rule.OrgID, + OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{ e.rule.OrgID: { datasources.ActionQuery: []string{ diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 4dd8f9dc8a3..b5d66f1b360 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -306,6 +306,9 @@ func (u *SignedInUser) GetCacheKey() (string, error) { if u.IsApiKeyUser() { return fmt.Sprintf("%d-apikey-%d", u.OrgID, u.ApiKeyID), nil } + if u.IsServiceAccountUser() { // not considered a real user + return fmt.Sprintf("%d-service-%d", u.OrgID, u.UserID), nil + } return "", ErrNoUniqueID } From 978f1119d7d6774a4d6fc9acb779a32cb82c8aa0 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 4 Nov 2022 17:06:47 -0400 Subject: [PATCH 064/926] Alerting: Run state manager as regular sub-service (#58246) --- pkg/services/ngalert/ngalert.go | 4 ++ pkg/services/ngalert/schedule/schedule.go | 2 - pkg/services/ngalert/state/manager.go | 46 +++++++++-------------- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index b1e23e36420..d61b07fe9c0 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -282,6 +282,10 @@ func (ng *AlertNG) Run(ctx context.Context) error { children, subCtx := errgroup.WithContext(ctx) + children.Go(func() error { + return ng.stateManager.Run(subCtx) + }) + children.Go(func() error { return ng.MultiOrgAlertmanager.Run(subCtx) }) diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index ea9a3e5eb1c..b33c0de0d6d 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -298,8 +298,6 @@ func (sch *schedule) schedulePeriodic(ctx context.Context, t *ticker.T) error { case <-ctx.Done(): // waiting for all rule evaluation routines to stop waitErr := dispatcherGroup.Wait() - // close the state manager and flush the state - sch.stateManager.Close() return waitErr } } diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index ad27bb68278..d46b5414a6a 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -15,7 +15,10 @@ import ( ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" ) -var ResendDelay = 30 * time.Second +var ( + ResendDelay = 30 * time.Second + MetricsScrapeInterval = 15 * time.Second // TODO: parameterize? // Setting to a reasonable default scrape interval for Prometheus. +) // AlertInstanceManager defines the interface for querying the current alert instances. type AlertInstanceManager interface { @@ -29,7 +32,6 @@ type Manager struct { clock clock.Clock cache *cache - quit chan struct{} ResendDelay time.Duration instanceStore InstanceStore @@ -39,9 +41,8 @@ type Manager struct { } func NewManager(metrics *metrics.State, externalURL *url.URL, instanceStore InstanceStore, imageService image.ImageService, clock clock.Clock, historian Historian) *Manager { - manager := &Manager{ + return &Manager{ cache: newCache(), - quit: make(chan struct{}), ResendDelay: ResendDelay, // TODO: make this configurable log: log.New("ngalert.state.manager"), metrics: metrics, @@ -51,14 +52,21 @@ func NewManager(metrics *metrics.State, externalURL *url.URL, instanceStore Inst clock: clock, externalURL: externalURL, } - if manager.metrics != nil { - go manager.recordMetrics() - } - return manager } -func (st *Manager) Close() { - st.quit <- struct{}{} +func (st *Manager) Run(ctx context.Context) error { + ticker := st.clock.Ticker(MetricsScrapeInterval) + for { + select { + case <-ticker.C: + st.log.Debug("Recording state cache metrics", "now", st.clock.Now()) + st.cache.recordMetrics(st.metrics) + case <-ctx.Done(): + st.log.Debug("Stopping") + ticker.Stop() + return ctx.Err() + } + } } func (st *Manager) Warm(ctx context.Context, rulesReader RuleReader) { @@ -269,24 +277,6 @@ func (st *Manager) GetStatesForRuleUID(orgID int64, alertRuleUID string) []*Stat return st.cache.getStatesForRuleUID(orgID, alertRuleUID) } -func (st *Manager) recordMetrics() { - // TODO: parameterize? - // Setting to a reasonable default scrape interval for Prometheus. - dur := time.Duration(15) * time.Second - ticker := st.clock.Ticker(dur) - for { - select { - case <-ticker.C: - st.log.Debug("Recording state cache metrics", "now", st.clock.Now()) - st.cache.recordMetrics(st.metrics) - case <-st.quit: - st.log.Debug("Stopping state cache metrics recording", "now", st.clock.Now()) - ticker.Stop() - return - } - } -} - func (st *Manager) Put(states []*State) { for _, s := range states { st.cache.set(s) From eb1cc80941393aeb006f0828353920753ec40891 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 4 Nov 2022 14:30:22 -0700 Subject: [PATCH 065/926] Storage: include SQL implementation (#58018) --- pkg/server/wire.go | 6 +- .../sqlstore/migrations/object_store_mig.go | 65 +- pkg/services/sqlstore/migrator/column.go | 1 + .../sqlstore/migrator/mysql_dialect.go | 6 +- pkg/services/sqlstore/sqlstore.go | 1 + .../store/object/sqlstash/querybuilder.go | 78 ++ .../object/sqlstash/sql_storage_server.go | 731 ++++++++++++++++++ .../store/object/sqlstash/summary_handler.go | 96 +++ pkg/services/store/object/sqlstash/utils.go | 27 + 9 files changed, 978 insertions(+), 33 deletions(-) create mode 100644 pkg/services/store/object/sqlstash/querybuilder.go create mode 100644 pkg/services/store/object/sqlstash/sql_storage_server.go create mode 100644 pkg/services/store/object/sqlstash/summary_handler.go create mode 100644 pkg/services/store/object/sqlstash/utils.go diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 687c7ad96dc..1123c997893 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -126,8 +126,8 @@ import ( "github.com/grafana/grafana/pkg/services/star/starimpl" "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/services/store/kind" - objectdummyserver "github.com/grafana/grafana/pkg/services/store/object/dummy" "github.com/grafana/grafana/pkg/services/store/object/httpobjectstore" + "github.com/grafana/grafana/pkg/services/store/object/sqlstash" "github.com/grafana/grafana/pkg/services/store/resolver" "github.com/grafana/grafana/pkg/services/store/sanitizer" "github.com/grafana/grafana/pkg/services/tag" @@ -360,8 +360,8 @@ var wireBasicSet = wire.NewSet( grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, interceptors.ProvideAuthenticator, - kind.ProvideService, // The registry known kinds - objectdummyserver.ProvideDummyObjectServer, + kind.ProvideService, // The registry of known kinds + sqlstash.ProvideSQLObjectServer, resolver.ProvideObjectReferenceResolver, httpobjectstore.ProvideHTTPObjectStore, teamimpl.ProvideService, diff --git a/pkg/services/sqlstore/migrations/object_store_mig.go b/pkg/services/sqlstore/migrations/object_store_mig.go index 27bea6cbcef..f2053e33407 100644 --- a/pkg/services/sqlstore/migrations/object_store_mig.go +++ b/pkg/services/sqlstore/migrations/object_store_mig.go @@ -8,36 +8,47 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +func getKeyColumn(name string, isPrimaryKey bool) *migrator.Column { + return &migrator.Column{ + Name: name, + Type: migrator.DB_NVarchar, + Length: 1024, + Nullable: false, + IsPrimaryKey: isPrimaryKey, + IsLatin: true, // only used in MySQL + } +} + func addObjectStorageMigrations(mg *migrator.Migrator) { tables := []migrator.Table{} tables = append(tables, migrator.Table{ Name: "object", Columns: []*migrator.Column{ - // Object key contains everything required to make it unique across all instances - // orgId+scope+kind+uid - {Name: "key", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false, IsPrimaryKey: true}, + // Object path contains everything required to make it unique across all instances + // orgId + scope + kind + uid + getKeyColumn("path", true), // This is an optimization for listing everything at the same level in the object store - {Name: "parent_folder_key", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + getKeyColumn("parent_folder_path", false), // The object type {Name: "kind", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, // The raw object body (any byte array) - {Name: "body", Type: migrator.DB_Blob, Nullable: false}, + {Name: "body", Type: migrator.DB_LongBlob, Nullable: false}, {Name: "size", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "etag", Type: migrator.DB_NVarchar, Length: 32, Nullable: false}, // md5(body) + {Name: "etag", Type: migrator.DB_NVarchar, Length: 32, Nullable: false, IsLatin: true}, // md5(body) {Name: "version", Type: migrator.DB_NVarchar, Length: 128, Nullable: false}, // Who changed what when -- We should avoid JOINs with other tables in the database - {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, - {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated_at", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "created_at", Type: migrator.DB_BigInt, Nullable: false}, {Name: "updated_by", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, {Name: "created_by", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, // For objects that are synchronized from an external source (ie provisioning or git) {Name: "sync_src", Type: migrator.DB_Text, Nullable: true}, - {Name: "sync_time", Type: migrator.DB_DateTime, Nullable: true}, + {Name: "sync_time", Type: migrator.DB_BigInt, Nullable: true}, // Summary data (always extracted from the `body` column) {Name: "name", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, @@ -46,22 +57,21 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { {Name: "fields", Type: migrator.DB_Text, Nullable: true}, // JSON object {Name: "errors", Type: migrator.DB_Text, Nullable: true}, // JSON object }, - PrimaryKeys: []string{"key"}, Indices: []*migrator.Index{ - {Cols: []string{"parent_folder_key"}}, // list in folder - {Cols: []string{"kind"}}, // filter by type + {Cols: []string{"parent_folder_path"}}, // list in folder + {Cols: []string{"kind"}}, // filter by type }, }) tables = append(tables, migrator.Table{ Name: "object_labels", Columns: []*migrator.Column{ - {Name: "key", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + getKeyColumn("path", false), {Name: "label", Type: migrator.DB_NVarchar, Length: 191, Nullable: false}, {Name: "value", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, }, Indices: []*migrator.Index{ - {Cols: []string{"key", "label"}, Type: migrator.UniqueIndex}, + {Cols: []string{"path", "label"}, Type: migrator.UniqueIndex}, }, }) @@ -69,7 +79,7 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { Name: "object_ref", Columns: []*migrator.Column{ // Source: - {Name: "key", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + getKeyColumn("path", false), // Address (defined in the body, not resolved, may be invalid and change) {Name: "kind", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, @@ -78,12 +88,12 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { // Runtime calcs (will depend on the system state) {Name: "resolved_ok", Type: migrator.DB_Bool, Nullable: false}, - {Name: "resolved_to", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + getKeyColumn("resolved_to", false), {Name: "resolved_warning", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, {Name: "resolved_time", Type: migrator.DB_DateTime, Nullable: false}, // resolution cache timestamp }, Indices: []*migrator.Index{ - {Cols: []string{"key"}, Type: migrator.IndexType}, + {Cols: []string{"path"}, Type: migrator.IndexType}, {Cols: []string{"kind"}, Type: migrator.IndexType}, {Cols: []string{"resolved_to"}, Type: migrator.IndexType}, }, @@ -92,23 +102,23 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { tables = append(tables, migrator.Table{ Name: "object_history", Columns: []*migrator.Column{ - {Name: "key", Type: migrator.DB_NVarchar, Length: 1024, Nullable: false}, + getKeyColumn("path", false), {Name: "version", Type: migrator.DB_NVarchar, Length: 128, Nullable: false}, // Raw bytes - {Name: "body", Type: migrator.DB_Blob, Nullable: false}, + {Name: "body", Type: migrator.DB_LongBlob, Nullable: false}, {Name: "size", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "etag", Type: migrator.DB_NVarchar, Length: 32, Nullable: false}, // md5(body) + {Name: "etag", Type: migrator.DB_NVarchar, Length: 32, Nullable: false, IsLatin: true}, // md5(body) // Who changed what when - {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated_at", Type: migrator.DB_BigInt, Nullable: false}, {Name: "updated_by", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, // Commit message {Name: "message", Type: migrator.DB_Text, Nullable: false}, // defaults to empty string }, Indices: []*migrator.Index{ - {Cols: []string{"key", "version"}, Type: migrator.UniqueIndex}, + {Cols: []string{"path", "version"}, Type: migrator.UniqueIndex}, {Cols: []string{"updated_by"}, Type: migrator.IndexType}, }, }) @@ -124,19 +134,16 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { // Migration cleanups: given that this is a complex setup // that requires a lot of testing before we are ready to push out of dev // this script lets us easy wipe previous changes and initialize clean tables - suffix := " (v0)" // change this when we want to wipe and reset the object tables + suffix := " (v2)" // change this when we want to wipe and reset the object tables mg.AddMigration("ObjectStore init: cleanup"+suffix, migrator.NewRawSQLMigration(strings.TrimSpace(` DELETE FROM migration_log WHERE migration_id LIKE 'ObjectStore init%'; - DROP table if exists "object"; - DROP table if exists "object_ref"; - DROP table if exists "object_history"; - DROP table if exists "object_labels"; - DROP table if exists "object_alias"; - DROP table if exists "object_access"; `))) // Initialize all tables for t := range tables { + mg.AddMigration("ObjectStore init: drop "+tables[t].Name+suffix, migrator.NewRawSQLMigration( + fmt.Sprintf("DROP TABLE IF EXISTS %s", tables[t].Name), + )) mg.AddMigration("ObjectStore init: table "+tables[t].Name+suffix, migrator.NewAddTableMigration(tables[t])) for i := range tables[t].Indices { mg.AddMigration(fmt.Sprintf("ObjectStore init: index %s[%d]"+suffix, tables[t].Name, i), migrator.NewAddIndexMigration(tables[t], tables[t].Indices[i])) diff --git a/pkg/services/sqlstore/migrator/column.go b/pkg/services/sqlstore/migrator/column.go index 28cef60a94d..8cdc8752b17 100644 --- a/pkg/services/sqlstore/migrator/column.go +++ b/pkg/services/sqlstore/migrator/column.go @@ -11,6 +11,7 @@ type Column struct { Nullable bool IsPrimaryKey bool IsAutoIncrement bool + IsLatin bool Default string } diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go index 26ceb430079..78861d367a0 100644 --- a/pkg/services/sqlstore/migrator/mysql_dialect.go +++ b/pkg/services/sqlstore/migrator/mysql_dialect.go @@ -91,7 +91,11 @@ func (db *MySQLDialect) SQLType(c *Column) string { switch c.Type { case DB_Char, DB_Varchar, DB_NVarchar, DB_TinyText, DB_Text, DB_MediumText, DB_LongText: - res += " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + if c.IsLatin { + res += " CHARACTER SET latin1 COLLATE latin1_bin" + } else { + res += " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + } } return res diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 7e24b9af507..1e1bb85eb1c 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -483,6 +483,7 @@ var featuresEnabledDuringTests = []string{ featuremgmt.FlagDashboardPreviews, featuremgmt.FlagDashboardComments, featuremgmt.FlagPanelTitleSearch, + featuremgmt.FlagObjectStore, } // InitTestDBWithMigration initializes the test DB given custom migrations. diff --git a/pkg/services/store/object/sqlstash/querybuilder.go b/pkg/services/store/object/sqlstash/querybuilder.go new file mode 100644 index 00000000000..73e13a66c70 --- /dev/null +++ b/pkg/services/store/object/sqlstash/querybuilder.go @@ -0,0 +1,78 @@ +package sqlstash + +import "strings" + +type selectQuery struct { + fields []string // SELECT xyz + from string // FROM object + limit int + oneExtra bool + + where []string + args []interface{} +} + +func (q *selectQuery) addWhere(f string, val string) { + q.args = append(q.args, val) + q.where = append(q.where, f+"=?") +} + +func (q *selectQuery) addWhereIn(f string, vals []string) { + count := len(vals) + if count > 1 { + sb := strings.Builder{} + sb.WriteString(f) + sb.WriteString(" IN (") + for i := 0; i < count; i++ { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString("?") + q.args = append(q.args, vals[i]) + } + sb.WriteString(") ") + q.where = append(q.where, sb.String()) + } else if count == 1 { + q.addWhere(f, vals[0]) + } +} + +func (q *selectQuery) addWherePrefix(f string, v string) { + q.args = append(q.args, v+"%") + q.where = append(q.where, f+" LIKE ?") +} + +func (q *selectQuery) toQuery() (string, []interface{}) { + args := q.args + sb := strings.Builder{} + sb.WriteString("SELECT ") + sb.WriteString(strings.Join(q.fields, ",")) + sb.WriteString(" FROM ") + sb.WriteString(q.from) + + // Templated where string + where := len(q.where) + if where > 0 { + sb.WriteString(" WHERE ") + for i := 0; i < where; i++ { + if i > 0 { + sb.WriteString(" AND ") + } + sb.WriteString(q.where[i]) + } + } + + if q.limit > 0 || q.oneExtra { + limit := q.limit + if limit < 1 { + limit = 20 + q.limit = limit + } + if q.oneExtra { + limit = limit + 1 + } + sb.WriteString(" LIMIT ?") + args = append(args, limit) + } + return sb.String(), args +} diff --git a/pkg/services/store/object/sqlstash/sql_storage_server.go b/pkg/services/store/object/sqlstash/sql_storage_server.go new file mode 100644 index 00000000000..720e9eaeb01 --- /dev/null +++ b/pkg/services/store/object/sqlstash/sql_storage_server.go @@ -0,0 +1,731 @@ +package sqlstash + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/grpcserver" + "github.com/grafana/grafana/pkg/services/sqlstore/session" + "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/store/kind" + "github.com/grafana/grafana/pkg/services/store/kind/folder" + "github.com/grafana/grafana/pkg/services/store/object" + "github.com/grafana/grafana/pkg/services/store/resolver" + "github.com/grafana/grafana/pkg/services/store/router" + "github.com/grafana/grafana/pkg/setting" +) + +func ProvideSQLObjectServer(db db.DB, cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry, resolver resolver.ObjectReferenceResolver) object.ObjectStoreServer { + objectServer := &sqlObjectServer{ + sess: db.GetSqlxSession(), + log: log.New("sql-object-server"), + kinds: kinds, + resolver: resolver, + router: router.NewObjectStoreRouter(kinds), + } + object.RegisterObjectStoreServer(grpcServerProvider.GetServer(), objectServer) + return objectServer +} + +type sqlObjectServer struct { + log log.Logger + sess *session.SessionDB + kinds kind.KindRegistry + resolver resolver.ObjectReferenceResolver + router router.ObjectStoreRouter +} + +func getReadSelect(r *object.ReadObjectRequest) string { + fields := []string{ + "path", "kind", "version", + "size", "etag", "errors", // errors are always returned + "created_at", "created_by", + "updated_at", "updated_by", + "sync_src", "sync_time"} + + if r.WithBody { + fields = append(fields, `body`) + } + if r.WithSummary { + fields = append(fields, `name`, `description`, `labels`, `fields`) + } + return "SELECT " + strings.Join(fields, ",") + " FROM object WHERE " +} + +func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql.Rows, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { + path := "" // string (extract UID?) + var syncSrc sql.NullString + var syncTime sql.NullTime + raw := &object.RawObject{ + GRN: &object.GRN{}, + } + + summaryjson := &summarySupport{} + args := []interface{}{ + &path, &raw.GRN.Kind, &raw.Version, + &raw.Size, &raw.ETag, &summaryjson.errors, + &raw.Created, &raw.CreatedBy, + &raw.Updated, &raw.UpdatedBy, + &syncSrc, &syncTime, + } + if r.WithBody { + args = append(args, &raw.Body) + } + if r.WithSummary { + args = append(args, &summaryjson.name, &summaryjson.description, &summaryjson.labels, &summaryjson.fields) + } + + err := rows.Scan(args...) + if err != nil { + return nil, err + } + + if syncSrc.Valid || syncTime.Valid { + raw.Sync = &object.RawObjectSyncInfo{ + Source: syncSrc.String, + Time: syncTime.Time.UnixMilli(), + } + } + + // Get the GRN from key. TODO? save each part as a column? + info, _ := s.router.RouteFromKey(ctx, path) + if info.GRN != nil { + raw.GRN = info.GRN + } + + rsp := &object.ReadObjectResponse{ + Object: raw, + } + + if r.WithSummary || summaryjson.errors != nil { + summary, err := summaryjson.toObjectSummary() + if err != nil { + return nil, err + } + + js, err := json.Marshal(summary) + if err != nil { + return nil, err + } + rsp.SummaryJson = js + } + return rsp, nil +} + +func (s *sqlObjectServer) getObjectKey(ctx context.Context, grn *object.GRN) (router.ResourceRouteInfo, error) { + if grn == nil { + return router.ResourceRouteInfo{}, fmt.Errorf("missing grn") + } + user := store.UserFromContext(ctx) + if user == nil { + return router.ResourceRouteInfo{}, fmt.Errorf("can not find user in context") + } + if user.OrgID != grn.TenantId { + if grn.TenantId > 0 { + return router.ResourceRouteInfo{}, fmt.Errorf("invalid user (wrong tenant id)") + } + grn.TenantId = user.OrgID + } + return s.router.Route(ctx, grn) +} + +func (s *sqlObjectServer) Read(ctx context.Context, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { + if r.Version != "" { + return s.readFromHistory(ctx, r) + } + + route, err := s.getObjectKey(ctx, r.GRN) + if err != nil { + return nil, err + } + + args := []interface{}{route.Key} + where := "path=?" + + rows, err := s.sess.Query(ctx, getReadSelect(r)+where, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + if !rows.Next() { + return &object.ReadObjectResponse{}, nil + } + + return s.rowToReadObjectResponse(ctx, rows, r) +} + +func (s *sqlObjectServer) readFromHistory(ctx context.Context, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { + route, err := s.getObjectKey(ctx, r.GRN) + if err != nil { + return nil, err + } + + fields := []string{ + "body", "size", "etag", + "updated_at", "updated_by", + } + + rows, err := s.sess.Query(ctx, + "SELECT "+strings.Join(fields, ",")+ + " FROM object_history WHERE path=? AND version=?", route.Key, r.Version) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + // Version or key not found + if !rows.Next() { + return &object.ReadObjectResponse{}, nil + } + + raw := &object.RawObject{ + GRN: r.GRN, + } + rsp := &object.ReadObjectResponse{ + Object: raw, + } + err = rows.Scan(&raw.Body, &raw.Size, &raw.ETag, &raw.Updated, &raw.UpdatedBy) + if err != nil { + return nil, err + } + // For versioned files, the created+updated are the same + raw.Created = raw.Updated + raw.CreatedBy = raw.UpdatedBy + raw.Version = r.Version // from the query + + // Dynamically create the summary + if r.WithSummary { + builder := s.kinds.GetSummaryBuilder(r.GRN.Kind) + if builder != nil { + val, out, err := builder(ctx, r.GRN.UID, raw.Body) + if err == nil { + raw.Body = out // cleaned up + rsp.SummaryJson, err = json.Marshal(val) + if err != nil { + return nil, err + } + } + } + } + + // Clear the body if not requested + if !r.WithBody { + rsp.Object.Body = nil + } + + return rsp, err +} + +func (s *sqlObjectServer) BatchRead(ctx context.Context, b *object.BatchReadObjectRequest) (*object.BatchReadObjectResponse, error) { + if len(b.Batch) < 1 { + return nil, fmt.Errorf("missing querires") + } + + first := b.Batch[0] + args := []interface{}{} + constraints := []string{} + + for _, r := range b.Batch { + if r.WithBody != first.WithBody || r.WithSummary != first.WithSummary { + return nil, fmt.Errorf("requests must want the same things") + } + + route, err := s.getObjectKey(ctx, r.GRN) + if err != nil { + return nil, err + } + + where := "path=?" + args = append(args, route.Key) + if r.Version != "" { + return nil, fmt.Errorf("version not supported for batch read (yet?)") + } + constraints = append(constraints, where) + } + + req := b.Batch[0] + query := getReadSelect(req) + strings.Join(constraints, " OR ") + rows, err := s.sess.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + // TODO? make sure the results are in order? + rsp := &object.BatchReadObjectResponse{} + for rows.Next() { + r, err := s.rowToReadObjectResponse(ctx, rows, req) + if err != nil { + return nil, err + } + rsp.Results = append(rsp.Results, r) + } + return rsp, nil +} + +func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { + route, err := s.getObjectKey(ctx, r.GRN) + if err != nil { + return nil, err + } + grn := route.GRN + if grn == nil { + return nil, fmt.Errorf("invalid grn") + } + + modifier := store.UserFromContext(ctx) + if modifier == nil { + return nil, fmt.Errorf("can not find user in context") + } + + summary, body, err := s.prepare(ctx, r) + if err != nil { + return nil, err + } + + etag := createContentsHash(body) + path := route.Key + + rsp := &object.WriteObjectResponse{ + GRN: grn, + Status: object.WriteObjectResponse_CREATED, // Will be changed if not true + } + + // Make sure all parent folders exist + if grn.Scope == models.ObjectStoreScopeDrive { + err = s.ensureFolders(ctx, grn) + if err != nil { + return nil, err + } + } + + err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { + isUpdate := false + versionInfo, err := s.selectForUpdate(ctx, tx, path) + if err != nil { + return err + } + + // Same object + if versionInfo.ETag == etag { + rsp.Object = versionInfo + rsp.Status = object.WriteObjectResponse_UNCHANGED + return nil + } + + // Optimistic locking + if r.PreviousVersion != "" { + if r.PreviousVersion != versionInfo.Version { + return fmt.Errorf("optimistic lock failed") + } + } + + // Set the comment on this write + timestamp := time.Now().UnixMilli() + versionInfo.Comment = r.Comment + if versionInfo.Version == "" { + versionInfo.Version = "1" + } else { + // Increment the version + i, _ := strconv.ParseInt(versionInfo.Version, 0, 64) + if i < 1 { + i = timestamp + } + versionInfo.Version = fmt.Sprintf("%d", i+1) + isUpdate = true + } + + if isUpdate { + // Clear the labels+refs + if _, err := tx.Exec(ctx, "DELETE FROM object_labels WHERE path=?", path); err != nil { + return err + } + if _, err := tx.Exec(ctx, "DELETE FROM object_ref WHERE path=?", path); err != nil { + return err + } + } + + // 1. Add the `object_history` values + versionInfo.Size = int64(len(body)) + versionInfo.ETag = etag + versionInfo.Updated = timestamp + versionInfo.UpdatedBy = store.GetUserIDString(modifier) + _, err = tx.Exec(ctx, `INSERT INTO object_history (`+ + "path, version, message, "+ + "size, body, etag, "+ + "updated_at, updated_by) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + path, versionInfo.Version, versionInfo.Comment, + versionInfo.Size, body, versionInfo.ETag, + timestamp, versionInfo.UpdatedBy, + ) + if err != nil { + return err + } + + // 2. Add the labels rows + for k, v := range summary.model.Labels { + _, err = tx.Exec(ctx, + `INSERT INTO object_labels `+ + "(path, label, value) "+ + `VALUES (?, ?, ?)`, + path, k, v, + ) + if err != nil { + return err + } + } + + // 3. Add the references rows + for _, ref := range summary.model.References { + resolved, err := s.resolver.Resolve(ctx, ref) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `INSERT INTO object_ref (`+ + "path, kind, type, uid, "+ + "resolved_ok, resolved_to, resolved_warning, resolved_time) "+ + `VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + path, ref.Kind, ref.Type, ref.UID, + resolved.OK, resolved.Key, resolved.Warning, resolved.Timestamp, + ) + if err != nil { + return err + } + } + + // 5. Add/update the main `object` table + rsp.Object = versionInfo + if isUpdate { + rsp.Status = object.WriteObjectResponse_UPDATED + _, err = tx.Exec(ctx, "UPDATE object SET "+ + "body=?, size=?, etag=?, version=?, "+ + "updated_at=?, updated_by=?,"+ + "name=?, description=?,"+ + "labels=?, fields=?, errors=? "+ + "WHERE path=?", + body, versionInfo.Size, etag, versionInfo.Version, + timestamp, versionInfo.UpdatedBy, + summary.model.Name, summary.model.Description, + summary.labels, summary.fields, summary.errors, + path, + ) + return err + } + + // Insert the new row + _, err = tx.Exec(ctx, "INSERT INTO object ("+ + "path, parent_folder_path, kind, size, body, etag, version,"+ + "updated_at, updated_by, created_at, created_by,"+ + "name, description,"+ + "labels, fields, errors) "+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + path, getParentFolderPath(grn.Kind, path), grn.Kind, versionInfo.Size, body, etag, versionInfo.Version, + timestamp, versionInfo.UpdatedBy, timestamp, versionInfo.UpdatedBy, // created + updated are the same + summary.model.Name, summary.model.Description, + summary.labels, summary.fields, summary.errors, + ) + return err + }) + rsp.SummaryJson = summary.marshaled + if err != nil { + rsp.Status = object.WriteObjectResponse_ERROR + } + return rsp, err +} + +func (s *sqlObjectServer) selectForUpdate(ctx context.Context, tx *session.SessionTx, path string) (*object.ObjectVersionInfo, error) { + q := "SELECT etag,version,updated_at,size FROM object WHERE path=?" + if false { // TODO, MYSQL/PosgreSQL can lock the row " FOR UPDATE" + q += " FOR UPDATE" + } + rows, err := tx.Query(ctx, q, path) + if err != nil { + return nil, err + } + current := &object.ObjectVersionInfo{} + if rows.Next() { + err = rows.Scan(¤t.ETag, ¤t.Version, ¤t.Updated, ¤t.Size) + } + if err == nil { + err = rows.Close() + } + return current, err +} + +func (s *sqlObjectServer) prepare(ctx context.Context, r *object.WriteObjectRequest) (*summarySupport, []byte, error) { + grn := r.GRN + builder := s.kinds.GetSummaryBuilder(grn.Kind) + if builder == nil { + return nil, nil, fmt.Errorf("unsupported kind") + } + + summary, body, err := builder(ctx, grn.UID, r.Body) + if err != nil { + return nil, nil, err + } + + summaryjson, err := newSummarySupport(summary) + if err != nil { + return nil, nil, err + } + return summaryjson, body, nil +} + +func (s *sqlObjectServer) Delete(ctx context.Context, r *object.DeleteObjectRequest) (*object.DeleteObjectResponse, error) { + route, err := s.getObjectKey(ctx, r.GRN) + if err != nil { + return nil, err + } + path := route.Key + + rsp := &object.DeleteObjectResponse{} + err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { + results, err := tx.Exec(ctx, "DELETE FROM object WHERE path=?", path) + if err != nil { + return err + } + rows, err := results.RowsAffected() + if err != nil { + return err + } + if rows > 0 { + rsp.OK = true + } + + // TODO: keep history? would need current version bump, and the "write" would have to get from history + _, _ = tx.Exec(ctx, "DELETE FROM object_history WHERE path=?", path) + _, _ = tx.Exec(ctx, "DELETE FROM object_labels WHERE path=?", path) + _, _ = tx.Exec(ctx, "DELETE FROM object_ref WHERE path=?", path) + return nil + }) + return rsp, err +} + +func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRequest) (*object.ObjectHistoryResponse, error) { + route, err := s.getObjectKey(ctx, r.GRN) + if err != nil { + return nil, err + } + path := route.Key + + page := "" + args := []interface{}{path} + if r.NextPageToken != "" { + // args = append(args, r.NextPageToken) // TODO, need to get time from the version + // page = "AND updated <= ?" + return nil, fmt.Errorf("next page not supported yet") + } + + query := "SELECT version,size,etag,updated_at,updated_by,message \n" + + " FROM object_history \n" + + " WHERE path=? " + page + "\n" + + " ORDER BY updated_at DESC LIMIT 100" + + rows, err := s.sess.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + rsp := &object.ObjectHistoryResponse{ + GRN: route.GRN, + } + for rows.Next() { + v := &object.ObjectVersionInfo{} + err := rows.Scan(&v.Version, &v.Size, &v.ETag, &v.Updated, &v.UpdatedBy, &v.Comment) + if err != nil { + return nil, err + } + rsp.Versions = append(rsp.Versions, v) + } + return rsp, err +} + +func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequest) (*object.ObjectSearchResponse, error) { + user := store.UserFromContext(ctx) + if r.NextPageToken != "" || len(r.Sort) > 0 || len(r.Labels) > 0 { + return nil, fmt.Errorf("not yet supported") + } + + fields := []string{ + "path", "kind", "version", "errors", // errors are always returned + "updated_at", "updated_by", + "name", "description", // basic summary + } + + if r.WithBody { + fields = append(fields, "body") + } + if r.WithLabels { + fields = append(fields, "labels") + } + if r.WithFields { + fields = append(fields, "fields") + } + + selectQuery := selectQuery{ + fields: fields, + from: "object", // the table + args: []interface{}{}, + limit: int(r.Limit), + oneExtra: true, // request one more than the limit (and show next token if it exists) + } + + if len(r.Kind) > 0 { + selectQuery.addWhereIn("kind", r.Kind) + } + + // Locked to a folder or prefix + if r.Folder != "" { + if strings.HasSuffix(r.Folder, "/") { + return nil, fmt.Errorf("folder should not end with slash") + } + if strings.HasSuffix(r.Folder, "*") { + keyPrefix := fmt.Sprintf("%d/%s", user.OrgID, strings.ReplaceAll(r.Folder, "*", "")) + selectQuery.addWherePrefix("path", keyPrefix) + } else { + keyPrefix := fmt.Sprintf("%d/%s", user.OrgID, r.Folder) + selectQuery.addWhere("parent_folder_path", keyPrefix) + } + } else { + keyPrefix := fmt.Sprintf("%d/", user.OrgID) + selectQuery.addWherePrefix("path", keyPrefix) + } + + query, args := selectQuery.toQuery() + + fmt.Printf("\n\n-------------\n") + fmt.Printf("%s\n", query) + fmt.Printf("%v\n", args) + fmt.Printf("\n-------------\n\n") + + rows, err := s.sess.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + key := "" + rsp := &object.ObjectSearchResponse{} + for rows.Next() { + result := &object.ObjectSearchResult{ + GRN: &object.GRN{}, + } + summaryjson := summarySupport{} + + args := []interface{}{ + &key, &result.GRN.Kind, &result.Version, &summaryjson.errors, + &result.Updated, &result.UpdatedBy, + &result.Name, &summaryjson.description, + } + if r.WithBody { + args = append(args, &result.Body) + } + if r.WithLabels { + args = append(args, &summaryjson.labels) + } + if r.WithFields { + args = append(args, &summaryjson.fields) + } + + err = rows.Scan(args...) + if err != nil { + return rsp, err + } + + info, err := s.router.RouteFromKey(ctx, key) + if err != nil { + return rsp, err + } + result.GRN = info.GRN + + // found one more than requested + if len(rsp.Results) >= selectQuery.limit { + // TODO? should this encode start+offset? + rsp.NextPageToken = key + break + } + + if summaryjson.description != nil { + result.Description = *summaryjson.description + } + + if summaryjson.labels != nil { + b := []byte(*summaryjson.labels) + err = json.Unmarshal(b, &result.Labels) + if err != nil { + return rsp, err + } + } + + if summaryjson.fields != nil { + result.FieldsJson = []byte(*summaryjson.fields) + } + + if summaryjson.errors != nil { + result.ErrorJson = []byte(*summaryjson.errors) + } + + rsp.Results = append(rsp.Results, result) + } + return rsp, err +} + +func (s *sqlObjectServer) ensureFolders(ctx context.Context, objectgrn *object.GRN) error { + uid := objectgrn.UID + idx := strings.LastIndex(uid, "/") + var missing []*object.GRN + + for idx > 0 { + parent := uid[:idx] + grn := &object.GRN{ + TenantId: objectgrn.TenantId, + Scope: objectgrn.Scope, + Kind: models.StandardKindFolder, + UID: parent, + } + fr, err := s.router.Route(ctx, grn) + if err != nil { + return err + } + + // Not super efficient, but maybe it is OK? + results := []int64{} + err = s.sess.Select(ctx, &results, "SELECT 1 from object WHERE path=?", fr.Key) + if err != nil { + return err + } + if len(results) == 0 { + missing = append([]*object.GRN{grn}, missing...) + } + idx = strings.LastIndex(parent, "/") + } + + // walk though each missing element + for _, grn := range missing { + f := &folder.Model{ + Name: store.GuessNameFromUID(grn.UID), + } + fmt.Printf("CREATE Folder: %s\n", grn.UID) + body, err := json.Marshal(f) + if err != nil { + return err + } + _, err = s.Write(ctx, &object.WriteObjectRequest{ + GRN: grn, + Body: body, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/services/store/object/sqlstash/summary_handler.go b/pkg/services/store/object/sqlstash/summary_handler.go new file mode 100644 index 00000000000..7e2a65ab957 --- /dev/null +++ b/pkg/services/store/object/sqlstash/summary_handler.go @@ -0,0 +1,96 @@ +package sqlstash + +import ( + "encoding/json" + + "github.com/grafana/grafana/pkg/models" +) + +type summarySupport struct { + model *models.ObjectSummary + name string + description *string // null or empty + labels *string + fields *string + errors *string // should not allow saving with this! + marshaled []byte +} + +func newSummarySupport(summary *models.ObjectSummary) (*summarySupport, error) { + var err error + var js []byte + s := &summarySupport{ + model: summary, + } + if summary != nil { + s.marshaled, err = json.Marshal(summary) + if err != nil { + return s, err + } + + s.name = summary.Name + if summary.Description != "" { + s.description = &summary.Description + } + + if len(summary.Labels) > 0 { + js, err = json.Marshal(summary.Labels) + if err != nil { + return s, err + } + str := string(js) + s.labels = &str + } + + if len(summary.Fields) > 0 { + js, err = json.Marshal(summary.Fields) + if err != nil { + return s, err + } + str := string(js) + s.fields = &str + } + + if summary.Error != nil { + js, err = json.Marshal(summary.Error) + if err != nil { + return s, err + } + str := string(js) + s.errors = &str + } + } + return s, err +} + +func (s summarySupport) toObjectSummary() (*models.ObjectSummary, error) { + var err error + summary := &models.ObjectSummary{ + Name: s.name, + } + if s.description != nil { + summary.Description = *s.description + } + if s.labels != nil { + b := []byte(*s.labels) + err = json.Unmarshal(b, &summary.Labels) + if err != nil { + return summary, err + } + } + if s.fields != nil { + b := []byte(*s.fields) + err = json.Unmarshal(b, &summary.Fields) + if err != nil { + return summary, err + } + } + if s.errors != nil { + b := []byte(*s.errors) + err = json.Unmarshal(b, &summary.Error) + if err != nil { + return summary, err + } + } + return summary, err +} diff --git a/pkg/services/store/object/sqlstash/utils.go b/pkg/services/store/object/sqlstash/utils.go new file mode 100644 index 00000000000..a75ad845615 --- /dev/null +++ b/pkg/services/store/object/sqlstash/utils.go @@ -0,0 +1,27 @@ +package sqlstash + +import ( + "crypto/md5" + "encoding/hex" + "strings" + + "github.com/grafana/grafana/pkg/models" +) + +func createContentsHash(contents []byte) string { + hash := md5.Sum(contents) + return hex.EncodeToString(hash[:]) +} + +func getParentFolderPath(kind string, key string) string { + idx := strings.LastIndex(key, "/") + if idx < 0 { + return "" // ? + } + + // folder should have a parent up one directory + if kind == models.StandardKindFolder { + idx = strings.LastIndex(key[:idx], "/") + } + return key[:idx] +} From a83fdc6b879127d506f0ffc3639a25cdf36bf246 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 4 Nov 2022 15:33:02 -0700 Subject: [PATCH 066/926] Live: remove json exact converter (#58282) --- go.mod | 2 - .../live/pipeline/converter_json_exact.go | 163 ------------------ .../pipeline/converter_json_exact_test.go | 96 ----------- pkg/services/live/pipeline/devdata.go | 124 ------------- pkg/services/live/pipeline/goja_expression.go | 97 ----------- .../live/pipeline/goja_expression_test.go | 55 ------ pkg/services/live/pipeline/registry.go | 4 - .../live/pipeline/rule_builder_storage.go | 5 - 8 files changed, 546 deletions(-) delete mode 100644 pkg/services/live/pipeline/converter_json_exact.go delete mode 100644 pkg/services/live/pipeline/converter_json_exact_test.go delete mode 100644 pkg/services/live/pipeline/goja_expression.go delete mode 100644 pkg/services/live/pipeline/goja_expression_test.go diff --git a/go.mod b/go.mod index 855540e66f7..91574c955df 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( github.com/cortexproject/cortex v1.10.1-0.20211014125347-85c378182d0d github.com/crewjam/saml v0.4.8 github.com/denisenkom/go-mssqldb v0.12.0 - github.com/dop251/goja v0.0.0-20210804101310-32956a348b49 github.com/fatih/color v1.13.0 github.com/gchaincl/sqlhooks v1.3.0 github.com/getsentry/sentry-go v0.13.0 @@ -75,7 +74,6 @@ require ( github.com/mattn/go-sqlite3 v1.14.7 github.com/matttproud/golang_protobuf_extensions v1.0.2 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f - github.com/ohler55/ojg v1.12.9 github.com/opentracing/opentracing-go v1.2.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect diff --git a/pkg/services/live/pipeline/converter_json_exact.go b/pkg/services/live/pipeline/converter_json_exact.go deleted file mode 100644 index f20ae49d014..00000000000 --- a/pkg/services/live/pipeline/converter_json_exact.go +++ /dev/null @@ -1,163 +0,0 @@ -package pipeline - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/ohler55/ojg/jp" - "github.com/ohler55/ojg/oj" -) - -// ExactJsonConverter can convert JSON to a single data.Frame according to -// user-defined field configuration and value extraction rules. -type ExactJsonConverter struct { - config ExactJsonConverterConfig - nowTimeFunc func() time.Time -} - -func NewExactJsonConverter(c ExactJsonConverterConfig) *ExactJsonConverter { - return &ExactJsonConverter{config: c} -} - -const ConverterTypeJsonExact = "jsonExact" - -func (c *ExactJsonConverter) Type() string { - return ConverterTypeJsonExact -} - -func (c *ExactJsonConverter) Convert(_ context.Context, vars Vars, body []byte) ([]*ChannelFrame, error) { - obj, err := oj.Parse(body) - if err != nil { - return nil, err - } - - var fields []*data.Field - - var initGojaOnce sync.Once - var gojaRuntime *gojaRuntime - - for _, f := range c.config.Fields { - field := data.NewFieldFromFieldType(f.Type, 1) - field.Name = f.Name - field.Config = f.Config - - if strings.HasPrefix(f.Value, "$") { - // JSON path. - fragments, err := jp.ParseString(f.Value[1:]) - if err != nil { - return nil, err - } - values := fragments.Get(obj) - if len(values) == 0 { - field.Set(0, nil) - } else if len(values) == 1 { - val := values[0] - switch f.Type { - case data.FieldTypeNullableFloat64: - if val == nil { - field.Set(0, nil) - } else { - switch v := val.(type) { - case float64: - field.SetConcrete(0, v) - case int64: - field.SetConcrete(0, float64(v)) - default: - return nil, fmt.Errorf("malformed float64 type for %s: %T", f.Name, v) - } - } - case data.FieldTypeNullableString: - v, ok := val.(string) - if !ok { - return nil, errors.New("malformed string type") - } - field.SetConcrete(0, v) - default: - return nil, fmt.Errorf("unsupported field type: %s (%s)", f.Type, f.Name) - } - } else { - return nil, errors.New("too many values") - } - } else if strings.HasPrefix(f.Value, "{") { - // Goja script. - script := strings.Trim(f.Value, "{}") - var err error - initGojaOnce.Do(func() { - gojaRuntime, err = getRuntime(body) - }) - if err != nil { - return nil, err - } - switch f.Type { - case data.FieldTypeNullableBool: - v, err := gojaRuntime.getBool(script) - if err != nil { - return nil, err - } - field.SetConcrete(0, v) - case data.FieldTypeNullableFloat64: - v, err := gojaRuntime.getFloat64(script) - if err != nil { - return nil, err - } - field.SetConcrete(0, v) - default: - return nil, fmt.Errorf("unsupported field type: %s (%s)", f.Type, f.Name) - } - } else if f.Value == "#{now}" { - // Variable. - // TODO: make consistent with Grafana variables? - nowTimeFunc := c.nowTimeFunc - if nowTimeFunc == nil { - nowTimeFunc = time.Now - } - field.SetConcrete(0, nowTimeFunc()) - } - - labels := map[string]string{} - for _, label := range f.Labels { - if strings.HasPrefix(label.Value, "$") { - fragments, err := jp.ParseString(label.Value[1:]) - if err != nil { - return nil, err - } - values := fragments.Get(obj) - if len(values) == 0 { - labels[label.Name] = "" - } else if len(values) == 1 { - labels[label.Name] = fmt.Sprintf("%v", values[0]) - } else { - return nil, errors.New("too many values for a label") - } - } else if strings.HasPrefix(label.Value, "{") { - script := strings.Trim(label.Value, "{}") - var err error - initGojaOnce.Do(func() { - gojaRuntime, err = getRuntime(body) - }) - if err != nil { - return nil, err - } - v, err := gojaRuntime.getString(script) - if err != nil { - return nil, err - } - labels[label.Name] = v - } else { - labels[label.Name] = label.Value - } - } - field.Labels = labels - fields = append(fields, field) - } - - frame := data.NewFrame(vars.Path, fields...) - return []*ChannelFrame{ - {Channel: "", Frame: frame}, - }, nil -} diff --git a/pkg/services/live/pipeline/converter_json_exact_test.go b/pkg/services/live/pipeline/converter_json_exact_test.go deleted file mode 100644 index 9781cd54c83..00000000000 --- a/pkg/services/live/pipeline/converter_json_exact_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package pipeline - -import ( - "context" - "testing" - "time" - - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana-plugin-sdk-go/experimental" - "github.com/stretchr/testify/require" -) - -func checkExactConversion(t *testing.T, file string, fields []Field) *backend.DataResponse { - t.Helper() - content := loadTestJson(t, file) - - converter := NewExactJsonConverter(ExactJsonConverterConfig{ - Fields: fields, - }) - converter.nowTimeFunc = func() time.Time { - return time.Date(2021, 01, 01, 12, 12, 12, 0, time.UTC) - } - channelFrames, err := converter.Convert(context.Background(), Vars{}, content) - require.NoError(t, err) - - dr := &backend.DataResponse{} - for _, cf := range channelFrames { - require.Empty(t, cf.Channel) - dr.Frames = append(dr.Frames, cf.Frame) - } - - experimental.CheckGoldenJSONResponse(t, "testdata", file+".golden", dr, *update) - return dr -} - -func BenchmarkExactJsonConverter_Convert(b *testing.B) { - content := loadTestJson(b, "json_exact") - - converter := NewExactJsonConverter(ExactJsonConverterConfig{ - Fields: []Field{ - { - Name: "ax", - Value: "$.ax", - Type: data.FieldTypeNullableFloat64, - }, { - Name: "array_value", - Value: "$.string_array[0]", - Type: data.FieldTypeNullableString, - }, { - Name: "map_key", - Value: "$.map_with_floats['key1']", - Type: data.FieldTypeNullableFloat64, - }, - }, - }) - - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := converter.Convert(context.Background(), Vars{}, content) - require.NoError(b, err) - //require.Len(b, cf, 1) - //require.Len(b, cf[0].Frame.Fields, 3) - } -} - -func TestExactJsonConverter_Convert(t *testing.T) { - checkExactConversion(t, "json_exact", []Field{ - { - Name: "time", - Value: "#{now}", - Type: data.FieldTypeTime, - }, - { - Name: "ax", - Value: "$.ax", - Type: data.FieldTypeNullableFloat64, - }, - { - Name: "key1", - Value: "{x.map_with_floats.key1}", - Type: data.FieldTypeNullableFloat64, - Labels: []Label{ - { - Name: "label1", - Value: "{x.map_with_floats.key2.toString()}", - }, - { - Name: "label2", - Value: "$.map_with_floats.key2", - }, - }, - }, - }) -} diff --git a/pkg/services/live/pipeline/devdata.go b/pkg/services/live/pipeline/devdata.go index 5f1e0e305d7..3d6b8ad7e54 100644 --- a/pkg/services/live/pipeline/devdata.go +++ b/pkg/services/live/pipeline/devdata.go @@ -208,130 +208,6 @@ func (f *DevRuleBuilder) BuildRules(_ context.Context, _ int64) ([]*LiveChannelR NewManagedStreamFrameOutput(f.ManagedStream), }, }, - { - OrgId: 1, - Pattern: "stream/json/exact", - Converter: NewExactJsonConverter(ExactJsonConverterConfig{ - Fields: []Field{ - { - Name: "time", - Type: data.FieldTypeTime, - Value: "#{now}", - }, - { - Name: "value1", - Type: data.FieldTypeNullableFloat64, - Value: "$.value1", - }, - { - Name: "value2", - Type: data.FieldTypeNullableFloat64, - Value: "$.value2", - }, - { - Name: "value3", - Type: data.FieldTypeNullableFloat64, - Value: "$.value3", - Labels: []Label{ - { - Name: "host", - Value: "$.host", - }, - }, - }, - { - Name: "value4", - Type: data.FieldTypeNullableFloat64, - Value: "$.value4", - Config: &data.FieldConfig{ - Thresholds: &data.ThresholdsConfig{ - Mode: data.ThresholdsModeAbsolute, - Steps: []data.Threshold{ - { - Value: 2, - State: "normal", - Color: "green", - }, - { - Value: 6, - State: "warning", - Color: "orange", - }, - { - Value: 8, - State: "critical", - Color: "red", - }, - }, - }, - }, - }, - { - Name: "map.red", - Type: data.FieldTypeNullableFloat64, - Value: "$.map.red", - Labels: []Label{ - { - Name: "host", - Value: "$.host", - }, - { - Name: "host2", - Value: "$.host", - }, - }, - }, - { - Name: "annotation", - Type: data.FieldTypeNullableString, - Value: "$.annotation", - }, - { - Name: "running", - Type: data.FieldTypeNullableBool, - Value: "{x.status === 'running'}", - }, - { - Name: "num_map_colors", - Type: data.FieldTypeNullableFloat64, - Value: "{Object.keys(x.map).length}", - }, - }, - }), - FrameOutputters: []FrameOutputter{ - NewManagedStreamFrameOutput(f.ManagedStream), - NewRemoteWriteFrameOutput( - os.Getenv("GF_LIVE_REMOTE_WRITE_ENDPOINT"), - &BasicAuth{ - User: os.Getenv("GF_LIVE_REMOTE_WRITE_USER"), - Password: os.Getenv("GF_LIVE_REMOTE_WRITE_PASSWORD"), - }, - 0, - ), - NewChangeLogFrameOutput(f.FrameStorage, ChangeLogOutputConfig{ - FieldName: "value3", - Channel: "stream/json/exact/value3/changes", - }), - NewChangeLogFrameOutput(f.FrameStorage, ChangeLogOutputConfig{ - FieldName: "annotation", - Channel: "stream/json/exact/annotation/changes", - }), - NewConditionalOutput( - NewMultipleFrameConditionChecker( - ConditionAll, - NewFrameNumberCompareCondition("value1", "gte", 3.0), - NewFrameNumberCompareCondition("value2", "gte", 3.0), - ), - NewRedirectFrameOutput(RedirectOutputConfig{ - Channel: "stream/json/exact/condition", - }), - ), - NewThresholdOutput(f.FrameStorage, ThresholdOutputConfig{ - FieldName: "value4", - Channel: "stream/json/exact/value4/state", - }), - }, - }, { OrgId: 1, Pattern: "stream/json/exact/value3/changes", diff --git a/pkg/services/live/pipeline/goja_expression.go b/pkg/services/live/pipeline/goja_expression.go deleted file mode 100644 index 0ce1b126de0..00000000000 --- a/pkg/services/live/pipeline/goja_expression.go +++ /dev/null @@ -1,97 +0,0 @@ -package pipeline - -import ( - "errors" - "fmt" - "time" - - "github.com/dop251/goja" - "github.com/dop251/goja/parser" -) - -func getRuntime(payload []byte) (*gojaRuntime, error) { - vm := goja.New() - vm.SetMaxCallStackSize(64) - vm.SetParserOptions(parser.WithDisableSourceMaps) - r := &gojaRuntime{vm} - err := r.init(payload) - if err != nil { - return nil, err - } - return r, nil -} - -type gojaRuntime struct { - vm *goja.Runtime -} - -// Parse JSON once. -func (r *gojaRuntime) init(payload []byte) error { - err := r.vm.Set("__body", string(payload)) - if err != nil { - return err - } - _, err = r.runString(`var x = JSON.parse(__body)`) - return err -} - -func (r *gojaRuntime) runString(script string) (goja.Value, error) { - doneCh := make(chan struct{}) - go func() { - select { - case <-doneCh: - return - case <-time.After(100 * time.Millisecond): - // Some ideas to prevent misuse of scripts: - // * parse/validate scripts on save - // * block scripts after several timeouts in a row - // * block scripts on malformed returned error - // * limit total quota of time for scripts - // * maybe allow only one statement, reject scripts with cycles and functions. - r.vm.Interrupt(errors.New("timeout")) - } - }() - defer close(doneCh) - return r.vm.RunString(script) -} - -func (r *gojaRuntime) getBool(script string) (bool, error) { - v, err := r.runString(script) - if err != nil { - return false, err - } - num, ok := v.Export().(bool) - if !ok { - return false, errors.New("unexpected return value") - } - return num, nil -} - -func (r *gojaRuntime) getString(script string) (string, error) { - v, err := r.runString(script) - if err != nil { - return "", err - } - exportedVal := v.Export() - stringVal, ok := exportedVal.(string) - if !ok { - return "", fmt.Errorf("unexpected return value: %v (%T), script: %s", exportedVal, exportedVal, script) - } - return stringVal, nil -} - -func (r *gojaRuntime) getFloat64(script string) (float64, error) { - v, err := r.runString(script) - if err != nil { - return 0, err - } - exported := v.Export() - switch v := exported.(type) { - case float64: - return v, nil - case int64: - return float64(v), nil - default: - return 0, fmt.Errorf("unexpected return value: %T", exported) - } -} diff --git a/pkg/services/live/pipeline/goja_expression_test.go b/pkg/services/live/pipeline/goja_expression_test.go deleted file mode 100644 index 1f7cc518a91..00000000000 --- a/pkg/services/live/pipeline/goja_expression_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package pipeline - -import ( - "testing" - - "github.com/dop251/goja" - "github.com/stretchr/testify/require" -) - -func TestGojaGetBool(t *testing.T) { - r, err := getRuntime([]byte(`{"ax": true}`)) - require.NoError(t, err) - val, err := r.getBool("x.ax") - require.NoError(t, err) - require.True(t, val) -} - -func TestGojaGetFloat64(t *testing.T) { - r, err := getRuntime([]byte(`{"ax": 3}`)) - require.NoError(t, err) - val, err := r.getFloat64("x.ax") - require.NoError(t, err) - require.Equal(t, 3.0, val) -} - -func TestGojaGetString(t *testing.T) { - r, err := getRuntime([]byte(`{"ax": "test"}`)) - require.NoError(t, err) - val, err := r.getString("x.ax") - require.NoError(t, err) - require.Equal(t, "test", val) -} - -func TestGojaInvalidReturnValue(t *testing.T) { - r, err := getRuntime([]byte(`{"ax": "test"}`)) - require.NoError(t, err) - _, err = r.getBool("x.ax") - require.Error(t, err) -} - -func TestGojaIInterrupt(t *testing.T) { - r, err := getRuntime([]byte(`{}`)) - require.NoError(t, err) - _, err = r.getBool("while (true) {}") - var interrupted *goja.InterruptedError - require.ErrorAs(t, err, &interrupted) -} - -func TestGojaIMaxStack(t *testing.T) { - r, err := getRuntime([]byte(`{}`)) - require.NoError(t, err) - _, err = r.getBool("function test() {test()}; test();") - // TODO: strange error returned here, need to investigate what is it. - require.Error(t, err) -} diff --git a/pkg/services/live/pipeline/registry.go b/pkg/services/live/pipeline/registry.go index dd44f18bd5c..3cd1ddde1b9 100644 --- a/pkg/services/live/pipeline/registry.go +++ b/pkg/services/live/pipeline/registry.go @@ -55,10 +55,6 @@ var ConvertersRegistry = []EntityInfo{ Type: ConverterTypeJsonAuto, Description: "automatic recursive JSON to Frame conversion", }, - { - Type: ConverterTypeJsonExact, - Description: "JSON to Frame conversion according to exact list of fields", - }, { Type: ConverterTypeInfluxAuto, Description: "accept influx line protocol", diff --git a/pkg/services/live/pipeline/rule_builder_storage.go b/pkg/services/live/pipeline/rule_builder_storage.go index 26142a0a652..99c5002d3bb 100644 --- a/pkg/services/live/pipeline/rule_builder_storage.go +++ b/pkg/services/live/pipeline/rule_builder_storage.go @@ -58,11 +58,6 @@ func (f *StorageRuleBuilder) extractConverter(config *ConverterConfig) (Converte config.AutoJsonConverterConfig = &AutoJsonConverterConfig{} } return NewAutoJsonConverter(*config.AutoJsonConverterConfig), nil - case ConverterTypeJsonExact: - if config.ExactJsonConverterConfig == nil { - return nil, missingConfiguration - } - return NewExactJsonConverter(*config.ExactJsonConverterConfig), nil case ConverterTypeJsonFrame: if config.JsonFrameConverterConfig == nil { config.JsonFrameConverterConfig = &JsonFrameConverterConfig{} From bff19747bdbd07c511b5351c66b110a6f8edc129 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 4 Nov 2022 22:00:46 -0700 Subject: [PATCH 067/926] Timeseries: Use standard editor for fillBelowTo field picker (#58283) --- .betterer.results | 58 ++++++++++--------- .../src/utils/OptionsUIBuilders.ts | 14 +++++ .../panel/timeseries/FillBelowToEditor.tsx | 48 --------------- public/app/plugins/panel/timeseries/config.ts | 13 ++--- 4 files changed, 49 insertions(+), 84 deletions(-) delete mode 100644 public/app/plugins/panel/timeseries/FillBelowToEditor.tsx diff --git a/.betterer.results b/.betterer.results index 1ae0fcc785d..d6008a4036f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -711,9 +711,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "36"], [0, 0, 0, "Unexpected any. Specify a different type.", "37"], [0, 0, 0, "Unexpected any. Specify a different type.", "38"], - [0, 0, 0, "Unexpected any. Specify a different type.", "39"], + [0, 0, 0, "Do not use any type assertions.", "39"], [0, 0, 0, "Unexpected any. Specify a different type.", "40"], - [0, 0, 0, "Unexpected any. Specify a different type.", "41"], + [0, 0, 0, "Do not use any type assertions.", "41"], [0, 0, 0, "Unexpected any. Specify a different type.", "42"], [0, 0, 0, "Unexpected any. Specify a different type.", "43"], [0, 0, 0, "Unexpected any. Specify a different type.", "44"], @@ -723,38 +723,43 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "48"], [0, 0, 0, "Unexpected any. Specify a different type.", "49"], [0, 0, 0, "Unexpected any. Specify a different type.", "50"], - [0, 0, 0, "Do not use any type assertions.", "51"], + [0, 0, 0, "Unexpected any. Specify a different type.", "51"], [0, 0, 0, "Unexpected any. Specify a different type.", "52"], - [0, 0, 0, "Do not use any type assertions.", "53"], + [0, 0, 0, "Unexpected any. Specify a different type.", "53"], [0, 0, 0, "Unexpected any. Specify a different type.", "54"], - [0, 0, 0, "Do not use any type assertions.", "55"], - [0, 0, 0, "Unexpected any. Specify a different type.", "56"], - [0, 0, 0, "Do not use any type assertions.", "57"], - [0, 0, 0, "Unexpected any. Specify a different type.", "58"], - [0, 0, 0, "Do not use any type assertions.", "59"], - [0, 0, 0, "Unexpected any. Specify a different type.", "60"], - [0, 0, 0, "Do not use any type assertions.", "61"], - [0, 0, 0, "Unexpected any. Specify a different type.", "62"], - [0, 0, 0, "Do not use any type assertions.", "63"], - [0, 0, 0, "Unexpected any. Specify a different type.", "64"], + [0, 0, 0, "Unexpected any. Specify a different type.", "55"], + [0, 0, 0, "Do not use any type assertions.", "56"], + [0, 0, 0, "Unexpected any. Specify a different type.", "57"], + [0, 0, 0, "Do not use any type assertions.", "58"], + [0, 0, 0, "Unexpected any. Specify a different type.", "59"], + [0, 0, 0, "Do not use any type assertions.", "60"], + [0, 0, 0, "Unexpected any. Specify a different type.", "61"], + [0, 0, 0, "Do not use any type assertions.", "62"], + [0, 0, 0, "Unexpected any. Specify a different type.", "63"], + [0, 0, 0, "Do not use any type assertions.", "64"], [0, 0, 0, "Unexpected any. Specify a different type.", "65"], [0, 0, 0, "Do not use any type assertions.", "66"], [0, 0, 0, "Unexpected any. Specify a different type.", "67"], - [0, 0, 0, "Unexpected any. Specify a different type.", "68"], - [0, 0, 0, "Do not use any type assertions.", "69"], + [0, 0, 0, "Do not use any type assertions.", "68"], + [0, 0, 0, "Unexpected any. Specify a different type.", "69"], [0, 0, 0, "Unexpected any. Specify a different type.", "70"], - [0, 0, 0, "Unexpected any. Specify a different type.", "71"], - [0, 0, 0, "Do not use any type assertions.", "72"], + [0, 0, 0, "Do not use any type assertions.", "71"], + [0, 0, 0, "Unexpected any. Specify a different type.", "72"], [0, 0, 0, "Unexpected any. Specify a different type.", "73"], - [0, 0, 0, "Unexpected any. Specify a different type.", "74"], - [0, 0, 0, "Do not use any type assertions.", "75"], + [0, 0, 0, "Do not use any type assertions.", "74"], + [0, 0, 0, "Unexpected any. Specify a different type.", "75"], [0, 0, 0, "Unexpected any. Specify a different type.", "76"], - [0, 0, 0, "Unexpected any. Specify a different type.", "77"], - [0, 0, 0, "Do not use any type assertions.", "78"], + [0, 0, 0, "Do not use any type assertions.", "77"], + [0, 0, 0, "Unexpected any. Specify a different type.", "78"], [0, 0, 0, "Unexpected any. Specify a different type.", "79"], - [0, 0, 0, "Unexpected any. Specify a different type.", "80"], - [0, 0, 0, "Do not use any type assertions.", "81"], - [0, 0, 0, "Unexpected any. Specify a different type.", "82"] + [0, 0, 0, "Do not use any type assertions.", "80"], + [0, 0, 0, "Unexpected any. Specify a different type.", "81"], + [0, 0, 0, "Unexpected any. Specify a different type.", "82"], + [0, 0, 0, "Do not use any type assertions.", "83"], + [0, 0, 0, "Unexpected any. Specify a different type.", "84"], + [0, 0, 0, "Unexpected any. Specify a different type.", "85"], + [0, 0, 0, "Do not use any type assertions.", "86"], + [0, 0, 0, "Unexpected any. Specify a different type.", "87"] ], "packages/grafana-data/src/utils/Registry.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -8184,9 +8189,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/plugins/panel/timeseries/FillBelowToEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/panel/timeseries/LineStyleEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index 3ce6b10d217..3bd634853e8 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -129,6 +129,20 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder settings: config.settings || {}, }); } + + addFieldNamePicker( + config: FieldConfigEditorConfig + ): this { + return this.addCustomEditor({ + ...config, + id: config.path, + editor: standardEditorsRegistry.get('field-name').editor as any, + override: standardEditorsRegistry.get('field-name').editor as any, + process: identityOverrideProcessor, + shouldApply: config.shouldApply ? config.shouldApply : () => true, + settings: config.settings || {}, + }); + } } export interface NestedValueAccess { diff --git a/public/app/plugins/panel/timeseries/FillBelowToEditor.tsx b/public/app/plugins/panel/timeseries/FillBelowToEditor.tsx deleted file mode 100644 index 207b1ec8ba6..00000000000 --- a/public/app/plugins/panel/timeseries/FillBelowToEditor.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { useMemo } from 'react'; - -import { FieldOverrideEditorProps, FieldType, getFieldDisplayName, SelectableValue } from '@grafana/data'; -import { Select } from '@grafana/ui'; - -export const FillBellowToEditor: React.FC> = ({ value, context, onChange }) => { - const names = useMemo(() => { - const names: Array> = []; - if (context.data.length) { - for (const frame of context.data) { - for (const field of frame.fields) { - if (field.type === FieldType.number) { - const label = getFieldDisplayName(field, frame, context.data); - names.push({ - label, - value: label, - }); - } - } - } - } - return names; - }, [context]); - - const current = useMemo(() => { - const found = names.find((v) => v.value === value); - if (found) { - return found; - } - if (value) { - return { - label: value, - value, - }; - } - return undefined; - }, [names, value]); - - return ( -
- + + + + + + {displayRolePicker && } + + + ); +}; diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index d02e2300f4d..dfd0ba2e199 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -1,3 +1,5 @@ +import { debounce } from 'lodash'; + import { getBackendSrv } from '@grafana/runtime'; import { updateNavIndex } from 'app/core/actions'; import { contextSrv } from 'app/core/core'; @@ -5,24 +7,35 @@ import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { AccessControlAction, TeamMember, ThunkResult } from 'app/types'; import { buildNavModel } from './navModel'; -import { teamGroupsLoaded, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers'; +import { teamGroupsLoaded, queryChanged, pageChanged, teamLoaded, teamMembersLoaded, teamsLoaded } from './reducers'; -export function loadTeams(): ThunkResult { - return async (dispatch) => { +export function loadTeams(initial = false): ThunkResult { + return async (dispatch, getState) => { + const { query, page, perPage } = getState().teams; // Early return if the user cannot list teams if (!contextSrv.hasPermission(AccessControlAction.ActionTeamsRead)) { - dispatch(teamsLoaded([])); + dispatch(teamsLoaded({ teams: [], totalCount: 0, page: 1, perPage, noTeams: true })); return; } const response = await getBackendSrv().get( '/api/teams/search', - accessControlQueryParam({ perpage: 1000, page: 1 }) + accessControlQueryParam({ query, page, perpage: perPage }) ); - dispatch(teamsLoaded(response.teams)); + + // We only want to check if there is no teams on the initial request. + // A query that returns no teams should not render the empty list banner. + let noTeams = false; + if (initial) { + noTeams = response.teams.length === 0; + } + + dispatch(teamsLoaded({ noTeams, ...response })); }; } +const loadTeamsWithDebounce = debounce((dispatch) => dispatch(loadTeams()), 500); + export function loadTeam(id: number): ThunkResult { return async (dispatch) => { const response = await getBackendSrv().get(`/api/teams/${id}`, accessControlQueryParam()); @@ -31,6 +44,29 @@ export function loadTeam(id: number): ThunkResult { }; } +export function deleteTeam(id: number): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`/api/teams/${id}`); + // Update users permissions in case they lost teams.read with the deletion + await contextSrv.fetchUserPermissions(); + dispatch(loadTeams()); + }; +} + +export function changeQuery(query: string): ThunkResult { + return async (dispatch) => { + dispatch(queryChanged(query)); + loadTeamsWithDebounce(dispatch); + }; +} + +export function changePage(page: number): ThunkResult { + return async (dispatch) => { + dispatch(pageChanged(page)); + dispatch(loadTeams()); + }; +} + export function loadTeamMembers(): ThunkResult { return async (dispatch, getStore) => { const team = getStore().team.team; @@ -87,15 +123,6 @@ export function removeTeamGroup(groupId: string): ThunkResult { }; } -export function deleteTeam(id: number): ThunkResult { - return async (dispatch) => { - await getBackendSrv().delete(`/api/teams/${id}`); - // Update users permissions in case they lost teams.read with the deletion - await contextSrv.fetchUserPermissions(); - dispatch(loadTeams()); - }; -} - export function updateTeamMember(member: TeamMember): ThunkResult { return async (dispatch) => { await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, { diff --git a/public/app/features/teams/state/reducers.test.ts b/public/app/features/teams/state/reducers.test.ts index 5482f349650..ba4984bd7be 100644 --- a/public/app/features/teams/state/reducers.test.ts +++ b/public/app/features/teams/state/reducers.test.ts @@ -6,9 +6,9 @@ import { initialTeamsState, initialTeamState, setSearchMemberQuery, - setSearchQuery, teamGroupsLoaded, teamLoaded, + queryChanged, teamMembersLoaded, teamReducer, teamsLoaded, @@ -20,11 +20,17 @@ describe('teams reducer', () => { it('then state should be correct', () => { reducerTester() .givenReducer(teamsReducer, { ...initialTeamsState }) - .whenActionIsDispatched(teamsLoaded([getMockTeam()])) + .whenActionIsDispatched( + teamsLoaded({ teams: [getMockTeam()], page: 1, perPage: 30, noTeams: false, totalCount: 100 }) + ) .thenStateShouldEqual({ ...initialTeamsState, hasFetched: true, teams: [getMockTeam()], + noTeams: false, + totalPages: 4, + perPage: 30, + page: 1, }); }); }); @@ -33,10 +39,10 @@ describe('teams reducer', () => { it('then state should be correct', () => { reducerTester() .givenReducer(teamsReducer, { ...initialTeamsState }) - .whenActionIsDispatched(setSearchQuery('test')) + .whenActionIsDispatched(queryChanged('test')) .thenStateShouldEqual({ ...initialTeamsState, - searchQuery: 'test', + query: 'test', }); }); }); diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts index 5c3f193b21c..58d6f45fe5c 100644 --- a/public/app/features/teams/state/reducers.ts +++ b/public/app/features/teams/state/reducers.ts @@ -2,25 +2,43 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types'; -export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', searchPage: 1, hasFetched: false }; +export const initialTeamsState: TeamsState = { + teams: [], + page: 1, + query: '', + perPage: 30, + totalPages: 0, + noTeams: false, + hasFetched: false, +}; + +type TeamsFetched = { + teams: Team[]; + page: number; + perPage: number; + noTeams: boolean; + totalCount: number; +}; const teamsSlice = createSlice({ name: 'teams', initialState: initialTeamsState, reducers: { - teamsLoaded: (state, action: PayloadAction): TeamsState => { - return { ...state, hasFetched: true, teams: action.payload }; + teamsLoaded: (state, action: PayloadAction): TeamsState => { + const { totalCount, perPage, ...rest } = action.payload; + const totalPages = Math.ceil(totalCount / perPage); + return { ...state, ...rest, totalPages, perPage, hasFetched: true }; }, - setSearchQuery: (state, action: PayloadAction): TeamsState => { - return { ...state, searchQuery: action.payload, searchPage: initialTeamsState.searchPage }; + queryChanged: (state, action: PayloadAction): TeamsState => { + return { ...state, page: 1, query: action.payload }; }, - setTeamsSearchPage: (state, action: PayloadAction): TeamsState => { - return { ...state, searchPage: action.payload }; + pageChanged: (state, action: PayloadAction): TeamsState => { + return { ...state, page: action.payload }; }, }, }); -export const { teamsLoaded, setSearchQuery, setTeamsSearchPage } = teamsSlice.actions; +export const { teamsLoaded, queryChanged, pageChanged } = teamsSlice.actions; export const teamsReducer = teamsSlice.reducer; diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts index 850943fb64b..2d86f980cff 100644 --- a/public/app/features/teams/state/selectors.test.ts +++ b/public/app/features/teams/state/selectors.test.ts @@ -1,29 +1,9 @@ import { User } from 'app/core/services/context_srv'; -import { Team, TeamGroup, TeamsState, TeamState, OrgRole } from '../../../types'; -import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks'; +import { Team, TeamGroup, TeamState, OrgRole } from '../../../types'; +import { getMockTeam, getMockTeamMembers } from '../__mocks__/teamMocks'; -import { getTeam, getTeamMembers, getTeams, isSignedInUserTeamAdmin, Config } from './selectors'; - -describe('Teams selectors', () => { - describe('Get teams', () => { - const mockTeams = getMultipleMockTeams(5); - - it('should return teams if no search query', () => { - const mockState: TeamsState = { teams: mockTeams, searchQuery: '', searchPage: 1, hasFetched: false }; - - const teams = getTeams(mockState); - expect(teams).toEqual(mockTeams); - }); - - it('Should filter teams if search query', () => { - const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', searchPage: 1, hasFetched: false }; - - const teams = getTeams(mockState); - expect(teams.length).toEqual(1); - }); - }); -}); +import { getTeam, getTeamMembers, isSignedInUserTeamAdmin, Config } from './selectors'; describe('Team selectors', () => { describe('Get team', () => { diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts index a374645fd53..eb20ed2e1a3 100644 --- a/public/app/features/teams/state/selectors.ts +++ b/public/app/features/teams/state/selectors.ts @@ -1,11 +1,8 @@ import { User } from 'app/core/services/context_srv'; -import { Team, TeamsState, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types'; +import { Team, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types'; -export const getSearchQuery = (state: TeamsState) => state.searchQuery; export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery; export const getTeamGroups = (state: TeamState) => state.groups; -export const getTeamsCount = (state: TeamsState) => state.teams.length; -export const getTeamsSearchPage = (state: TeamsState) => state.searchPage; export const getTeam = (state: TeamState, currentTeamId: any): Team | null => { if (state.team.id === parseInt(currentTeamId, 10)) { @@ -15,14 +12,6 @@ export const getTeam = (state: TeamState, currentTeamId: any): Team | null => { return null; }; -export const getTeams = (state: TeamsState) => { - const regex = RegExp(state.searchQuery, 'i'); - - return state.teams.filter((team) => { - return regex.test(team.name); - }); -}; - export const getTeamMembers = (state: TeamState) => { const regex = RegExp(state.searchMemberQuery, 'i'); diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index 99739affb95..07617caaa0a 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -29,8 +29,11 @@ export interface TeamGroup { export interface TeamsState { teams: Team[]; - searchQuery: string; - searchPage: number; + page: number; + query: string; + perPage: number; + noTeams: boolean; + totalPages: number; hasFetched: boolean; } From 9a5a3443048804eee528ce50b6b8c77c3e855f3b Mon Sep 17 00:00:00 2001 From: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Wed, 16 Nov 2022 16:05:30 +0100 Subject: [PATCH 265/926] Explore: A11y of range slider in query history (#58708) refactor: remove temporary setting --- packages/grafana-ui/src/components/Slider/RangeSlider.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/grafana-ui/src/components/Slider/RangeSlider.tsx b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx index b8b1f4d436c..2c9f4a5f1dd 100644 --- a/packages/grafana-ui/src/components/Slider/RangeSlider.tsx +++ b/packages/grafana-ui/src/components/Slider/RangeSlider.tsx @@ -73,8 +73,6 @@ export const RangeSlider: FunctionComponent = ({ onAfterChange={handleAfterChange} vertical={!isHorizontal} reverse={reverse} - // TODO: The following is a temporary work around for making content after the slider accessible and it will be removed when fixing the slider in public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx. - tabIndex={[0, 1]} handleRender={tipHandleRender} /> From 934fb2f0ee037b9e73b6fce6a792e347b795471b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 16 Nov 2022 15:17:24 +0000 Subject: [PATCH 266/926] QueryData: fix header parsing to support expressions (#58826) fixes #58821 --- pkg/services/query/query.go | 8 ++++++++ pkg/services/query/query_test.go | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 02803abf92b..773d0577f43 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -259,6 +259,14 @@ func (pr parsedRequest) validateRequest() error { return nil } + if pr.hasExpression { + hasExpr := pr.httpRequest.URL.Query().Get("expression") + if hasExpr == "" || hasExpr == "true" { + return nil + } + return ErrQueryParamMismatch + } + vals := splitHeaders(pr.httpRequest.Header.Values(HeaderDatasourceUID)) count := len(vals) if count > 0 { // header exists diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index f5d931c8784..e8da4ab6481 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -288,8 +288,16 @@ func TestQueryDataMultipleSources(t *testing.T) { HTTPRequest: nil, } + // without query parameter _, err = tc.queryService.QueryData(context.Background(), tc.signedInUser, true, reqDTO) + require.NoError(t, err) + httpreq, _ := http.NewRequest(http.MethodPost, "http://localhost/ds/query?expression=true", bytes.NewReader([]byte{})) + httpreq.Header.Add("X-Datasource-Uid", "gIEkMvIVz") + reqDTO.HTTPRequest = httpreq + + // with query parameter + _, err = tc.queryService.QueryData(context.Background(), tc.signedInUser, true, reqDTO) require.NoError(t, err) }) From 7bf3e28e8f8dfd8f379a902d58cf4468dd15043b Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Wed, 16 Nov 2022 15:23:49 +0000 Subject: [PATCH 267/926] Chore: skip flaky tests (#58835) skip flaky tests --- .../tests/querylibrary_integration_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/services/querylibrary/tests/querylibrary_integration_test.go b/pkg/services/querylibrary/tests/querylibrary_integration_test.go index b1d87726cb5..b0112cd49db 100644 --- a/pkg/services/querylibrary/tests/querylibrary_integration_test.go +++ b/pkg/services/querylibrary/tests/querylibrary_integration_test.go @@ -13,6 +13,11 @@ import ( ) func TestIntegrationCreateAndDelete(t *testing.T) { + if true { + // TODO: re-enable after fixing its flakiness + t.Skip() + } + if testing.Short() { t.Skip("skipping integration test") } @@ -124,6 +129,11 @@ func createQuery(t *testing.T, ctx context.Context, testCtx testContext) string } func TestIntegrationDashboardGetWithLatestSavedQueries(t *testing.T) { + if true { + // TODO: re-enable after fixing its flakiness + t.Skip() + } + if testing.Short() { t.Skip("skipping integration test") } From f2066398f022e0c2f4670122efa25f6e03991e9f Mon Sep 17 00:00:00 2001 From: Kyle Brandt Date: Wed, 16 Nov 2022 10:38:53 -0500 Subject: [PATCH 268/926] CodeOwners: (Chore) Add Server Side Expressions (SSE) (#58841) CodeOwners: Add Server Side Expressions (SSE) owned by observability-metrics --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a7115b5455a..92dfa9ee7c5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -198,6 +198,9 @@ lerna.json @grafana/frontend-ops /public/app/plugins/datasource/tempo @grafana/observability-traces-and-profiling /public/app/plugins/datasource/alertmanager @grafana/alerting-squad +# SSE - Server Side Expressions +/pkg/expr @grafana/observability-metrics + # Cloud middleware /grafana-mixin/ @grafana/hosted-grafana-team From 1953d473c04eb4a5b6c9f4231384ab0cb3dae8f3 Mon Sep 17 00:00:00 2001 From: Kyle Brandt Date: Wed, 16 Nov 2022 10:39:28 -0500 Subject: [PATCH 269/926] SSE: Keep value name from numeric table (#58831) fixes #48868 --- pkg/expr/nodes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index 21eb7c763b4..cd6fca63b8b 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -389,7 +389,7 @@ func extractNumberSet(frame *data.Frame) ([]mathexp.Number, error) { labels[key] = val.(string) // TODO check assertion / return error } - n := mathexp.NewNumber("", labels) + n := mathexp.NewNumber(frame.Fields[numericField].Name, labels) // The new value fields' configs gets pointed to the one in the original frame n.Frame.Fields[0].Config = frame.Fields[numericField].Config From f4d238cdbd8a28d270c409c5a82b0f4fb59c6eeb Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 16 Nov 2022 16:41:32 +0100 Subject: [PATCH 270/926] Build: Disable flaky RuleEditor frontend test (#58844) --- public/app/features/alerting/unified/RuleEditor.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index 9854c4b403e..f57a299869a 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -102,7 +102,9 @@ const ui = { const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); -describe('RuleEditor', () => { +// Until flakiness is fixed +// https://github.com/grafana/grafana/issues/58747 +describe.skip('RuleEditor', () => { beforeEach(() => { jest.clearAllMocks(); contextSrv.isEditor = true; From 8756c4d91f330efc365e1d7dd1abe1c3153d8ccf Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 16 Nov 2022 17:44:33 +0100 Subject: [PATCH 271/926] RBAC: Add tests on AddAppLinks (#58843) * RBAC: Add tests on AddAppLinks --- .../navtree/navtreeimpl/applinks_test.go | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index 8b6cbb7c6a4..de6ffacf500 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -6,8 +6,10 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/navtree" @@ -349,3 +351,121 @@ func TestReadingNavigationSettings(t *testing.T) { require.Equal(t, int64(30), service.navigationAppPathConfig["/a/grafana-k8s-app/foo"].SortWeight) }) } + +func TestAddAppLinksAccessControl(t *testing.T) { + httpReq, _ := http.NewRequest(http.MethodGet, "", nil) + user := &user.SignedInUser{OrgID: 1} + reqCtx := &models.ReqContext{SignedInUser: user, Context: &web.Context{Req: httpReq}} + catalogReadAction := "test-app1.catalog:read" + + testApp1 := plugins.PluginDTO{ + JSONData: plugins.JSONData{ + ID: "test-app1", Name: "Test app1 name", Type: plugins.App, + Includes: []*plugins.Includes{ + { + Name: "Catalog", + Path: "/a/test-app1/catalog", + Type: "page", + AddToNav: true, + DefaultNav: true, + Role: roletype.RoleEditor, + Action: catalogReadAction, + }, + { + Name: "Page2", + Path: "/a/test-app1/page2", + Type: "page", + AddToNav: true, + Role: roletype.RoleViewer, + }, + }, + }, + } + + pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{ + testApp1.ID: {ID: 0, OrgID: 1, PluginID: testApp1.ID, PluginVersion: "1.0.0", Enabled: true}, + }} + + cfg := setting.NewCfg() + + service := ServiceImpl{ + log: log.New("navtree"), + cfg: cfg, + accessControl: acimpl.ProvideAccessControl(cfg), + pluginSettings: &pluginSettings, + features: featuremgmt.WithFeatures(), + pluginStore: plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{testApp1}, + }, + } + + t.Run("Should not add app links when the user cannot access app plugins", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{} + user.OrgRole = roletype.RoleAdmin + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 0) + }) + t.Run("Should add both includes when the user is an editor", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {plugins.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleEditor + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 1) + require.Equal(t, "Test app1 name", treeRoot.Children[0].Text) + require.Len(t, treeRoot.Children[0].Children, 2) + require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Children[0].Url) + require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].Url) + }) + t.Run("Should add one include when the user is a viewer", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {plugins.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleViewer + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 1) + require.Equal(t, "Test app1 name", treeRoot.Children[0].Text) + require.Len(t, treeRoot.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[0].Url) + }) + t.Run("Should add both includes when the user is a viewer with catalog read", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {plugins.ActionAppAccess: []string{"*"}, catalogReadAction: []string{}}, + } + user.OrgRole = roletype.RoleViewer + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 1) + require.Equal(t, "Test app1 name", treeRoot.Children[0].Text) + require.Len(t, treeRoot.Children[0].Children, 2) + require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Children[0].Url) + require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].Url) + }) + t.Run("Should add one include when the user is an editor without catalog read", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {plugins.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleEditor + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 1) + require.Equal(t, "Test app1 name", treeRoot.Children[0].Text) + require.Len(t, treeRoot.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[0].Url) + }) +} From aea860a3bd4e7a4e0599484683635f14ecbd96b6 Mon Sep 17 00:00:00 2001 From: matt abrams <37156449+zuchka@users.noreply.github.com> Date: Wed, 16 Nov 2022 17:46:12 +0100 Subject: [PATCH 272/926] DataLinks: Fix double dollar-sign bug in data-links editor (#58096) * fix template variable bug * fix bug when adding vars before existing vars * take 2--remove includeDollarSign logic * take 3-add includeDollarSign logic for template vars --- packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index 127bf984819..b2e3d907610 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -141,7 +141,7 @@ export const DataLinkInput: React.FC = memo( if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`); } else { - editor.insertText(`\${${item.value}:queryparam}`); + editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}:queryparam}`); } setLinkUrl(editor.value); From f254a37d359709ea800d8fbf633088a7adf8b851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Calisto?= Date: Wed, 16 Nov 2022 17:11:26 +0000 Subject: [PATCH 273/926] Middleware: Add CSP Report Only support (#58074) * Middleware: Add CSP Report Only support * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update csp documentation wording * Update conf/sample.ini Co-authored-by: Dave Henderson * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Dave Henderson * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Dave Henderson * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Dave Henderson * Update pkg/middleware/csp.go Co-authored-by: Dave Henderson Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Dave Henderson --- conf/defaults.ini | 9 ++ conf/sample.ini | 8 ++ .../setup-grafana/configure-grafana/_index.md | 11 ++- pkg/api/http_server.go | 5 +- pkg/middleware/csp.go | 92 ++++++++++++------- pkg/middleware/middleware_test.go | 43 ++++++++- pkg/middleware/testing.go | 13 ++- pkg/setting/setting.go | 18 +++- 8 files changed, 158 insertions(+), 41 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index b071aabd65c..8e00ce19af8 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -329,6 +329,15 @@ content_security_policy = false # $ROOT_PATH is server.root_url without the protocol. content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" +# Enable adding the Content-Security-Policy-Report-Only header to your requests. +# Allows you to monitor the effects of a policy without enforcing it. +content_security_policy_report_only = false + +# Set Content Security Policy Report Only template used when adding the Content-Security-Policy-Report-Only header to your requests. +# $NONCE in the template includes a random nonce. +# $ROOT_PATH is server.root_url without the protocol. +content_security_policy_report_only_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" + # Controls if old angular plugins are supported or not. This will be disabled by default in future release angular_support_enabled = true diff --git a/conf/sample.ini b/conf/sample.ini index 30d3e0f65b8..6272b628544 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -330,6 +330,14 @@ # $ROOT_PATH is server.root_url without the protocol. ;content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" +# Enable adding the Content-Security-Policy-Report-Only header to your requests. +# Allows you to monitor the effects of a policy without enforcing it. +;content_security_policy_report_only = false + +# Set Content Security Policy Report Only template used when adding the Content-Security-Policy-Report-Only header to your requests. +# $NONCE in the template includes a random nonce. +# $ROOT_PATH is server.root_url without the protocol. +;content_security_policy_report_only_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" # Controls if old angular plugins are supported or not. This will be disabled by default in future release ;angular_support_enabled = true diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 0cd2970e338..d85f3324b27 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -623,7 +623,16 @@ Set to `true` to add the Content-Security-Policy header to your requests. CSP al ### content_security_policy_template -Set Content Security Policy template used when adding the Content-Security-Policy header to your requests. `$NONCE` in the template includes a random nonce. +Set the policy template that will be used when adding the `Content-Security-Policy` header to your requests. `$NONCE` in the template includes a random nonce. + +### content_security_policy_report_only + +Set to `true` to add the `Content-Security-Policy-Report-Only` header to your requests. CSP in Report Only mode enables you to experiment with policies by monitoring their effects without enforcing them. +You can enable both policies simultaneously. + +### content_security_policy_template + +Set the policy template that will be used when adding the `Content-Security-Policy-Report-Only` header to your requests. `$NONCE` in the template includes a random nonce.
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 9392c03a911..f2cfce24fc1 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -623,7 +623,10 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() { } m.Use(middleware.HandleNoCacheHeader) - m.UseMiddleware(middleware.AddCSPHeader(hs.Cfg, hs.log)) + + if hs.Cfg.CSPEnabled || hs.Cfg.CSPReportOnlyEnabled { + m.UseMiddleware(middleware.ContentSecurityPolicy(hs.Cfg, hs.log)) + } for _, mw := range hs.middlewares { m.Use(mw) diff --git a/pkg/middleware/csp.go b/pkg/middleware/csp.go index 2ea9614dfe0..c5bd8691768 100644 --- a/pkg/middleware/csp.go +++ b/pkg/middleware/csp.go @@ -14,40 +14,64 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -// AddCSPHeader adds the Content Security Policy header. -func AddCSPHeader(cfg *setting.Cfg, logger log.Logger) func(http.Handler) http.Handler { +// ContentSecurityPolicy sets the configured Content-Security-Policy and/or Content-Security-Policy-Report-Only header(s) in the response. +func ContentSecurityPolicy(cfg *setting.Cfg, logger log.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - if !cfg.CSPEnabled { - next.ServeHTTP(rw, req) - return - } - - logger.Debug("Adding CSP header to response", "cfg", fmt.Sprintf("%p", cfg)) - - ctx := contexthandler.FromContext(req.Context()) - if cfg.CSPTemplate == "" { - logger.Debug("CSP template not configured, so returning 500") - ctx.JsonApiErr(500, "CSP template has to be configured", nil) - return - } - - var buf [16]byte - if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { - logger.Error("Failed to generate CSP nonce", "err", err) - ctx.JsonApiErr(500, "Failed to generate CSP nonce", err) - } - - nonce := base64.RawStdEncoding.EncodeToString(buf[:]) - val := strings.ReplaceAll(cfg.CSPTemplate, "$NONCE", fmt.Sprintf("'nonce-%s'", nonce)) - - re := regexp.MustCompile(`^\w+:(//)?`) - rootPath := re.ReplaceAllString(cfg.AppURL, "") - val = strings.ReplaceAll(val, "$ROOT_PATH", rootPath) - rw.Header().Set("Content-Security-Policy", val) - ctx.RequestNonce = nonce - logger.Debug("Successfully generated CSP nonce", "nonce", nonce) - next.ServeHTTP(rw, req) - }) + if cfg.CSPEnabled { + next = cspMiddleware(cfg, next, logger) + } + if cfg.CSPReportOnlyEnabled { + next = cspReportOnlyMiddleware(cfg, next, logger) + } + next = nonceMiddleware(next, logger) + return next } } + +func nonceMiddleware(next http.Handler, logger log.Logger) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + ctx := contexthandler.FromContext(req.Context()) + nonce, err := generateNonce() + if err != nil { + logger.Error("Failed to generate CSP nonce", "err", err) + ctx.JsonApiErr(500, "Failed to generate CSP nonce", err) + } + ctx.RequestNonce = nonce + logger.Debug("Successfully generated CSP nonce", "nonce", nonce) + next.ServeHTTP(rw, req) + }) +} + +func cspMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + ctx := contexthandler.FromContext(req.Context()) + policy := replacePolicyVariables(cfg.CSPTemplate, cfg.AppURL, ctx.RequestNonce) + rw.Header().Set("Content-Security-Policy", policy) + next.ServeHTTP(rw, req) + }) +} + +func cspReportOnlyMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + ctx := contexthandler.FromContext(req.Context()) + policy := replacePolicyVariables(cfg.CSPReportOnlyTemplate, cfg.AppURL, ctx.RequestNonce) + rw.Header().Set("Content-Security-Policy-Report-Only", policy) + next.ServeHTTP(rw, req) + }) +} + +func replacePolicyVariables(policyTemplate, appURL, nonce string) string { + policy := strings.ReplaceAll(policyTemplate, "$NONCE", fmt.Sprintf("'nonce-%s'", nonce)) + re := regexp.MustCompile(`^\w+:(//)?`) + rootPath := re.ReplaceAllString(appURL, "") + policy = strings.ReplaceAll(policy, "$ROOT_PATH", rootPath) + return policy +} + +func generateNonce() (string, error) { + var buf [16]byte + if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(buf[:]), nil +} diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 28839e6198c..b1af65c32a4 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -83,6 +83,47 @@ func TestMiddleWareSecurityHeaders(t *testing.T) { }) } +func TestMiddleWareContentSecurityPolicyHeaders(t *testing.T) { + policy := `script-src 'self' 'strict-dynamic' 'nonce-[^']+';connect-src 'self' ws://localhost:3000/ wss://localhost:3000/;` + + middlewareScenario(t, "middleware should add Content-Security-Policy", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/").exec() + assert.Regexp(t, policy, sc.resp.Header().Get("Content-Security-Policy")) + }, func(cfg *setting.Cfg) { + cfg.CSPEnabled = true + cfg.CSPTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;" + cfg.AppURL = "http://localhost:3000/" + }) + + middlewareScenario(t, "middleware should add Content-Security-Policy-Report-Only", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/").exec() + assert.Regexp(t, policy, sc.resp.Header().Get("Content-Security-Policy-Report-Only")) + }, func(cfg *setting.Cfg) { + cfg.CSPReportOnlyEnabled = true + cfg.CSPReportOnlyTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;" + cfg.AppURL = "http://localhost:3000/" + }) + + middlewareScenario(t, "middleware can add both CSP and CSP-Report-Only", func(t *testing.T, sc *scenarioContext) { + sc.fakeReq("GET", "/api/").exec() + + cspHeader := sc.resp.Header().Get("Content-Security-Policy") + cspReportOnlyHeader := sc.resp.Header().Get("Content-Security-Policy-Report-Only") + + assert.Regexp(t, policy, cspHeader) + assert.Regexp(t, policy, cspReportOnlyHeader) + + // assert CSP-Report-Only reuses the same nonce as CSP + assert.Equal(t, cspHeader, cspReportOnlyHeader) + }, func(cfg *setting.Cfg) { + cfg.CSPEnabled = true + cfg.CSPTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;" + cfg.CSPReportOnlyEnabled = true + cfg.CSPReportOnlyTemplate = "script-src 'self' 'strict-dynamic' $NONCE;connect-src 'self' ws://$ROOT_PATH wss://$ROOT_PATH;" + cfg.AppURL = "http://localhost:3000/" + }) +} + func TestMiddlewareContext(t *testing.T) { const noCache = "no-cache" @@ -770,7 +811,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.m = web.New() sc.m.Use(AddDefaultResponseHeaders(cfg)) - sc.m.UseMiddleware(AddCSPHeader(cfg, logger)) + sc.m.UseMiddleware(ContentSecurityPolicy(cfg, logger)) sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]")) sc.mockSQLStore = dbtest.NewFakeDB() diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index e8feb484d97..a091f9118fe 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey/apikeytest" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -77,7 +78,11 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { sc.resp = httptest.NewRecorder() req, err := http.NewRequest(method, url, nil) require.NoError(sc.t, err) - sc.req = req + + reqCtx := &models.ReqContext{ + Context: web.FromContext(req.Context()), + } + sc.req = req.WithContext(ctxkey.Set(req.Context(), reqCtx)) return sc } @@ -95,7 +100,11 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map } req.URL.RawQuery = q.Encode() require.NoError(sc.t, err) - sc.req = req + + reqCtx := &models.ReqContext{ + Context: web.FromContext(req.Context()), + } + sc.req = req.WithContext(ctxkey.Set(req.Context(), reqCtx)) return sc } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 09250fd5781..546974bbe8c 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -263,7 +263,11 @@ type Cfg struct { // CSPEnabled toggles Content Security Policy support. CSPEnabled bool // CSPTemplate contains the Content Security Policy template. - CSPTemplate string + CSPTemplate string + // CSPReportEnabled toggles Content Security Policy Report Only support. + CSPReportOnlyEnabled bool + // CSPReportOnlyTemplate contains the Content Security Policy Report Only template. + CSPReportOnlyTemplate string AngularSupportEnabled bool TempDataLifetime time.Duration @@ -1285,9 +1289,19 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { cfg.StrictTransportSecurityMaxAge = security.Key("strict_transport_security_max_age_seconds").MustInt(86400) cfg.StrictTransportSecurityPreload = security.Key("strict_transport_security_preload").MustBool(false) cfg.StrictTransportSecuritySubDomains = security.Key("strict_transport_security_subdomains").MustBool(false) + cfg.AngularSupportEnabled = security.Key("angular_support_enabled").MustBool(true) cfg.CSPEnabled = security.Key("content_security_policy").MustBool(false) cfg.CSPTemplate = security.Key("content_security_policy_template").MustString("") - cfg.AngularSupportEnabled = security.Key("angular_support_enabled").MustBool(true) + cfg.CSPReportOnlyEnabled = security.Key("content_security_policy_report_only").MustBool(false) + cfg.CSPReportOnlyTemplate = security.Key("content_security_policy_report_only_template").MustString("") + + if cfg.CSPEnabled && cfg.CSPTemplate == "" { + return fmt.Errorf("enabling content_security_policy requires a content_security_policy_template configuration") + } + + if cfg.CSPReportOnlyEnabled && cfg.CSPReportOnlyTemplate == "" { + return fmt.Errorf("enabling content_security_policy_report_only requires a content_security_policy_report_only_template configuration") + } // read data source proxy whitelist DataProxyWhiteList = make(map[string]bool) From d9b8b761e9b2a6da4f9fdc88afc9a646e52f3364 Mon Sep 17 00:00:00 2001 From: matt abrams <37156449+zuchka@users.noreply.github.com> Date: Wed, 16 Nov 2022 18:22:06 +0100 Subject: [PATCH 274/926] Query Editor: Hide overflow for long query names (#58840) hides overflow for long query names --- public/app/features/query/components/QueryEditorRowHeader.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/app/features/query/components/QueryEditorRowHeader.tsx b/public/app/features/query/components/QueryEditorRowHeader.tsx index 4122e04eebc..e41f9e33286 100644 --- a/public/app/features/query/components/QueryEditorRowHeader.tsx +++ b/public/app/features/query/components/QueryEditorRowHeader.tsx @@ -153,6 +153,7 @@ const getStyles = (theme: GrafanaTheme2) => { display: flex; align-items: center; margin-left: ${theme.spacing(0.5)}; + overflow: hidden; `, queryNameWrapper: css` display: flex; @@ -163,6 +164,7 @@ const getStyles = (theme: GrafanaTheme2) => { padding: 0 0 0 ${theme.spacing(0.5)}; margin: 0; background: transparent; + overflow: hidden; &:hover { background: ${theme.colors.action.hover}; @@ -214,6 +216,7 @@ const getStyles = (theme: GrafanaTheme2) => { font-style: italic; color: ${theme.colors.text.secondary}; padding-left: 10px; + padding-right: 10px; `, itemWrapper: css` display: flex; From df27164b8e0167b1b6c277d3f5a0fbd64b520eab Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Wed, 16 Nov 2022 13:25:14 -0500 Subject: [PATCH 275/926] Changelog: Updated changelog for 9.2.5 (#58856) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e9d9e2506..c1efe236831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,33 @@ The following functions and classes related to logs are deprecated in the `grafa - **Toolkit:** Deprecate `component:create` command. [#56086](https://github.com/grafana/grafana/pull/56086), [@academo](https://github.com/academo) - **Toolkit:** Remove changelog command. [#56073](https://github.com/grafana/grafana/pull/56073), [@gitstart](https://github.com/gitstart) + + +# 9.2.5 (2022-11-16) + +### Features and enhancements + +- **Alerting:** Log when alert rule cannot be screenshot to help debugging. [#58537](https://github.com/grafana/grafana/pull/58537), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Suggest previously entered custom labels. [#57783](https://github.com/grafana/grafana/pull/57783), [@VikaCep](https://github.com/VikaCep) +- **Canvas:** Improve disabled inline editing UX. [#58610](https://github.com/grafana/grafana/pull/58610), [@nmarrs](https://github.com/nmarrs) +- **Canvas:** Improve disabled inline editing UX. [#58609](https://github.com/grafana/grafana/issues/58609) +- **Chore:** Upgrade go-sqlite3 to v1.14.16. [#58581](https://github.com/grafana/grafana/pull/58581), [@sakjur](https://github.com/sakjur) +- **Plugins:** Ensure CallResource responses contain valid Content-Type header. [#58506](https://github.com/grafana/grafana/pull/58506), [@xnyo](https://github.com/xnyo) +- **Prometheus:** Handle errors and warnings in buffered client. [#58657](https://github.com/grafana/grafana/pull/58657), [@itsmylife](https://github.com/itsmylife) +- **Prometheus:** Upgrade HTTP client library to v1.13.1. [#58363](https://github.com/grafana/grafana/pull/58363), [@marefr](https://github.com/marefr) + +### Bug fixes + +- **Alerting:** Fix screenshots were not cached. [#58493](https://github.com/grafana/grafana/pull/58493), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Canvas:** Fix setting icon from field data. [#58499](https://github.com/grafana/grafana/pull/58499), [@nmarrs](https://github.com/nmarrs) +- **Plugins:** Fix don't set Content-Type header if status is 204 for call resource. [#50780](https://github.com/grafana/grafana/pull/50780), [@sd2k](https://github.com/sd2k) + +### Plugin development fixes & changes + +- **Toolkit:** Fix compilation loop when watching plugins for changes. [#58167](https://github.com/grafana/grafana/pull/58167), [@jackw](https://github.com/jackw) +- **Tooltips:** Make tooltips in FormField and FormLabel interactive and keyboard friendly. [#57706](https://github.com/grafana/grafana/pull/57706), [@asimpson](https://github.com/asimpson) + + From 79f1a7a4fdf09479e7b37a8c5f428d6bd246bbe9 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 16 Nov 2022 19:29:33 +0100 Subject: [PATCH 276/926] Database: Adds support for enable/disable SQLite Write-Ahead Logging (WAL) via configuration (#58268) Adds support for enable/disable SQLite Write-Ahead Logging (WAL) via configuration. Enables SQLite WAL for E2E tests. --- conf/defaults.ini | 5 ++++- conf/sample.ini | 3 +++ docs/sources/setup-grafana/configure-grafana/_index.md | 4 ++++ pkg/services/sqlstore/sqlstore.go | 7 +++++++ scripts/grafana-server/custom.ini | 4 ++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 8e00ce19af8..023d23a53cb 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -134,6 +134,9 @@ path = grafana.db # For "sqlite3" only. cache mode setting used for connecting to the database cache_mode = private +# For "sqlite3" only. Enable/disable Write-Ahead Logging, https://sqlite.org/wal.html. Default is false. +wal = false + # For "mysql" only if migrationLocking feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0. locking_attempt_timeout_sec = 0 @@ -1147,7 +1150,7 @@ renderer_token = - # which this setting can help protect against by only allowing a certain amount of concurrent requests. concurrent_render_request_limit = 30 # Determines the lifetime of the render key used by the image renderer to access and render Grafana. -# This setting should be expressed as a duration. Examples: 10s (seconds), 5m (minutes), 2h (hours). +# This setting should be expressed as a duration. Examples: 10s (seconds), 5m (minutes), 2h (hours). # Default is 5m. This should be more than enough for most deployments. # Change the value only if image rendering is failing and you see `Failed to get the render key from cache` in Grafana logs. render_key_lifetime = 5m diff --git a/conf/sample.ini b/conf/sample.ini index 6272b628544..aacf119127c 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -136,6 +136,9 @@ # For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared) ;cache_mode = private +# For "sqlite3" only. Enable/disable Write-Ahead Logging, https://sqlite.org/wal.html. Default is false. +;wal = false + # For "mysql" only if migrationLocking feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0. ;locking_attempt_timeout_sec = 0 diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index d85f3324b27..bf938890362 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -380,6 +380,10 @@ will be stored. For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared) Defaults to `private`. +### wal + +For "sqlite3" only. Setting to enable/disable [Write-Ahead Logging](https://sqlite.org/wal.html). The default value is `false` (disabled). + ### query_retries This setting applies to `sqlite` only and controls the number of times the system retries a query when the database is locked. The default value is `0` (disabled). diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 1e1bb85eb1c..411c30d038b 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -325,6 +325,11 @@ func (ss *SQLStore) buildConnectionString() (string, error) { } cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode) + + if ss.dbCfg.WALEnabled { + cnnstr += "&_journal_mode=WAL" + } + cnnstr += ss.buildExtraConnectionString('&') default: return "", fmt.Errorf("unknown database type: %s", ss.dbCfg.Type) @@ -453,6 +458,7 @@ func (ss *SQLStore) readConfig() error { ss.dbCfg.IsolationLevel = sec.Key("isolation_level").String() ss.dbCfg.CacheMode = sec.Key("cache_mode").MustString("private") + ss.dbCfg.WALEnabled = sec.Key("wal").MustBool(false) ss.dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool() ss.dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt() @@ -677,6 +683,7 @@ type DatabaseConfig struct { MaxIdleConn int ConnMaxLifetime int CacheMode string + WALEnabled bool UrlQueryParams map[string][]string SkipMigrations bool MigrationLockAttemptTimeout int diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index 1d2c1bd3fa0..5256269baa3 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -1,2 +1,6 @@ [feature_toggles] enable = publicDashboards + +[database] +type=sqlite3 +wal=true From 88f5ed0faf9dd181fe11478f33977f6dd92aebd0 Mon Sep 17 00:00:00 2001 From: "lean.dev" <34773040+leandro-deveikis@users.noreply.github.com> Date: Wed, 16 Nov 2022 15:42:38 -0300 Subject: [PATCH 277/926] Chore: update latest.json to 9.2.5 (#58860) --- latest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/latest.json b/latest.json index ce531a0d71a..17efb856b65 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "9.2.4", + "stable": "9.2.5", "testing": "9.3.0-beta1" } From c4528f9bd84f8124285a09e3b4a5cc3564a205f7 Mon Sep 17 00:00:00 2001 From: sam boyer Date: Wed, 16 Nov 2022 15:08:01 -0500 Subject: [PATCH 278/926] codejen: Update to latest codejen (#58866) --- go.mod | 4 ++-- go.sum | 6 ++---- kinds/gen.go | 2 +- pkg/codegen/generators.go | 8 ++++++++ pkg/codegen/jenny_basecorereg.go | 2 +- pkg/codegen/jenny_tsveneerindex.go | 2 +- pkg/kindsys/EXTENDING.md | 2 +- pkg/plugins/plugindef/gen.go | 2 +- 8 files changed, 17 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 9edbe559b78..1e5d862639d 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/go-sql-driver/mysql v1.6.0 github.com/go-stack/stack v1.8.1 github.com/gobwas/glob v0.2.3 - github.com/gofrs/uuid v4.3.0+incompatible + github.com/gofrs/uuid v4.3.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 github.com/golang/mock v1.6.0 github.com/golang/snappy v0.0.4 @@ -253,7 +253,7 @@ require ( github.com/getkin/kin-openapi v0.103.0 github.com/golang-migrate/migrate/v4 v4.7.0 github.com/google/go-github/v45 v45.2.0 - github.com/grafana/codejen v0.0.2 + github.com/grafana/codejen v0.0.3 github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f github.com/jmoiron/sqlx v1.3.5 github.com/kr/pretty v0.3.0 diff --git a/go.sum b/go.sum index 9abdd47c2e5..88e4e481756 100644 --- a/go.sum +++ b/go.sum @@ -1348,8 +1348,8 @@ github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc= github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= -github.com/grafana/codejen v0.0.2 h1:Ssp27X7SOnYxaPUTByW/6201tNV5Q60l1BSF+s3lRP8= -github.com/grafana/codejen v0.0.2/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= +github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw= +github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM= github.com/grafana/cuetsy v0.1.1/go.mod h1:4KWkUOslwvRTpEv7wdQG0jDFTuJmU+0L9x0h4kWxa2A= github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f h1:FvvSVEbnGeM2bUivGmsiXTi8URJyBU7TcFEEoRe5wWI= @@ -1369,8 +1369,6 @@ github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293 github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293/go.mod h1:HVHqK+BVPa/tmL8EMhLCCrPt2a1GdJpEyxr5hgur2UI= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc h1:1PY8n+rXuBNr3r1JQhoytWDCpc+pq+BibxV0SZv+Cr4= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4= -github.com/grafana/thema v0.0.0-20221113034006-50fd3c0da5ce h1:N1K0WWaG0B5i/703ri0WSazQYVsCYj1mgODgElCz0o8= -github.com/grafana/thema v0.0.0-20221113034006-50fd3c0da5ce/go.mod h1:ZJHKwNE86ngdQ7edJIFHepCiIg9YP9x+YZPEm3dlkL4= github.com/grafana/thema v0.0.0-20221113112305-b441ed85a1fd h1:y6H9I5fy4sRKf2FJ7W94YWero4mXH50Ft8NAPZ9DapQ= github.com/grafana/thema v0.0.0-20221113112305-b441ed85a1fd/go.mod h1:ZJHKwNE86ngdQ7edJIFHepCiIg9YP9x+YZPEm3dlkL4= github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6 h1:I9dh1MXGX0wGyxdV/Sl7+ugnki4Dfsy8lv2s5Yf887o= diff --git a/kinds/gen.go b/kinds/gen.go index 9b58b05e79e..41f0d527049 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -104,7 +104,7 @@ func main() { return nameFor(all[i].Meta) < nameFor(all[j].Meta) }) - jfs, err := coreKindsGen.GenerateFS(all) + jfs, err := coreKindsGen.GenerateFS(all...) if err != nil { die(fmt.Errorf("core kinddirs codegen failed: %w", err)) } diff --git a/pkg/codegen/generators.go b/pkg/codegen/generators.go index 51d9075557d..a7b7aed83f8 100644 --- a/pkg/codegen/generators.go +++ b/pkg/codegen/generators.go @@ -34,12 +34,20 @@ func ForGen(rt *thema.Runtime, decl *kindsys.SomeDecl) (*DeclForGen, error) { type DeclForGen struct { *kindsys.SomeDecl lin thema.Lineage + sch thema.Lineage } +// Lineage returns the [thema.Lineage] for the underlying [kindsys.SomeDecl]. func (decl *DeclForGen) Lineage() thema.Lineage { return decl.lin } +// Schema returns the [thema.Schema] that a jenny should operate against, for those +// jennies that target a single schema. +func (decl *DeclForGen) Schema() thema.Lineage { + return decl.sch +} + // SlashHeaderMapper produces a FileMapper that injects a comment header onto // a [codejen.File] indicating the main generator that produced it (via the provided // maingen, which should be a path) and the jenny or jennies that constructed the diff --git a/pkg/codegen/jenny_basecorereg.go b/pkg/codegen/jenny_basecorereg.go index 96298b61cef..acbb6a50a85 100644 --- a/pkg/codegen/jenny_basecorereg.go +++ b/pkg/codegen/jenny_basecorereg.go @@ -31,7 +31,7 @@ func (gen *genBaseRegistry) JennyName() string { return "BaseCoreRegistryJenny" } -func (gen *genBaseRegistry) Generate(decls []*DeclForGen) (*codejen.File, error) { +func (gen *genBaseRegistry) Generate(decls ...*DeclForGen) (*codejen.File, error) { var numRaw int for _, k := range decls { if k.IsRaw() { diff --git a/pkg/codegen/jenny_tsveneerindex.go b/pkg/codegen/jenny_tsveneerindex.go index 0bb82482e5f..6052cc94bf5 100644 --- a/pkg/codegen/jenny_tsveneerindex.go +++ b/pkg/codegen/jenny_tsveneerindex.go @@ -39,7 +39,7 @@ func (gen *genTSVeneerIndex) JennyName() string { return "TSVeneerIndexJenny" } -func (gen *genTSVeneerIndex) Generate(decls []*DeclForGen) (*codejen.File, error) { +func (gen *genTSVeneerIndex) Generate(decls ...*DeclForGen) (*codejen.File, error) { tsf := new(ast.File) for _, decl := range decls { if decl.IsRaw() { diff --git a/pkg/kindsys/EXTENDING.md b/pkg/kindsys/EXTENDING.md index 93f36639f45..9aa7c125460 100644 --- a/pkg/kindsys/EXTENDING.md +++ b/pkg/kindsys/EXTENDING.md @@ -10,7 +10,7 @@ This document is the guide to extending kindsys. But first, we have to identify * **CUE framework** - the collection of .cue files in this directory, `pkg/kindsys`. These are schemas that define how Kinds are declared. * **Go framework** - the Go package in this directory containing utilities for loading individual kind declarations, validating them against the CUE framework, and representing them consistently in Go. -* **Code generators** - `pkg/codegen` contains the codegen framework. Individual generators (which take one or many `pkg/kindsys.Decl`, and produce a single file) each have a `pkg/codegen/generator_*.go` file. +* **Code generators** - written using the `github.com/grafana/codejen` framework, which applies the [single responsibility principle](https://en.wikipedia.org/wiki/Single-responsibility_principle) to code generation, allowing us to compose modular code generators. Each jenny - a modular generator with a single responsibility - is declared as a `pkg/codegen/jenny_*.go` file. * **Registries** - generated lists of all or a well-defined subset of kinds that can be used in code. `pkg/registries/corekind` is a registry of all core `pkg/kindsys.Interface` implementations; `packages/grafana-schema/src/index.gen.ts` is a registry of all the TypeScript types generated from the current versions of each kind's schema. * **Kind declarations** - the declarations of individual kinds. By kind category: * **Core Structured** - each child directory of `kinds/structured`. diff --git a/pkg/plugins/plugindef/gen.go b/pkg/plugins/plugindef/gen.go index 07354ec2c46..adaea3b315b 100644 --- a/pkg/plugins/plugindef/gen.go +++ b/pkg/plugins/plugindef/gen.go @@ -44,7 +44,7 @@ func main() { grootp := strings.Split(cwd, string(os.PathSeparator)) groot := filepath.Join(string(os.PathSeparator), filepath.Join(grootp[:len(grootp)-3]...)) - jfs := elsedie(jl.GenerateFS([]thema.Lineage{lin}))("plugindef jenny pipeline failed") + jfs := elsedie(jl.GenerateFS(lin))("plugindef jenny pipeline failed") if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { if err := jfs.Verify(context.Background(), groot); err != nil { die(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) From ab36252c86b4e173788478d4cab0c3e5f3398619 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu, 17 Nov 2022 00:30:27 +0100 Subject: [PATCH 279/926] Quota: Fix failure when checking session limits (#58865) --- pkg/api/common_test.go | 2 +- pkg/middleware/middleware_test.go | 2 +- pkg/services/contexthandler/auth_proxy_test.go | 2 +- pkg/services/contexthandler/contexthandler.go | 6 ++++++ pkg/services/quota/quotaimpl/quota.go | 8 +++----- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 714f1116412..e6f0c159401 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -214,7 +214,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, &usertest.FakeUserService{}, sqlStore) loginService := &logintest.LoginServiceFake{} authenticator := &logintest.AuthenticatorFake{} - ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures()) + ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures(), nil) return ctxHdlr } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index b1af65c32a4..fec44973196 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -874,7 +874,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.Fake tracer := tracing.InitializeTracerForTest() authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore) authenticator := &logintest.AuthenticatorFake{ExpectedUser: &user.User{}} - return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck)) + return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck), nil) } type fakeRenderService struct { diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 9e6a629ab2b..307ca2b51bc 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -104,7 +104,7 @@ func getContextHandler(t *testing.T) *ContextHandler { return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, - &userService, orgService, nil, nil) + &userService, orgService, nil, nil, nil) } type FakeGetSignUserStore struct { diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 0179a8bccf0..c8d67f165c0 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -48,6 +49,11 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service, orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, features *featuremgmt.FeatureManager, + // before 9.3.0 the quota service used to depend on on the ActiveTokenService + // since 9.3.0 after the quota refactoring ActiveTokenService depends on the quota + // therefore it's added to avoid cycle dependencies + // since it's used only by the middleware for enforcing quota limits. + activeTokenService auth.ActiveTokenService, ) *ContextHandler { return &ContextHandler{ Cfg: cfg, diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index e435989fbfb..f26c066b193 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -82,12 +82,10 @@ func (s *service) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) return false, nil } - var params *quota.ScopeParameters + params := "a.ScopeParameters{} if c.IsSignedIn { - params = "a.ScopeParameters{ - OrgID: c.OrgID, - UserID: c.UserID, - } + params.OrgID = c.OrgID + params.UserID = c.UserID } return s.CheckQuotaReached(c.Req.Context(), targetSrv, params) } From 27b6b3b3bddb0ab5f120c867556753b538885e4a Mon Sep 17 00:00:00 2001 From: Leo <108552997+lpskdl@users.noreply.github.com> Date: Thu, 17 Nov 2022 09:22:57 +0100 Subject: [PATCH 280/926] Folder: Replace folderId with folderUid (#58393) * support folderuid in FolderPicker * support folderuid in unified alerting * support folderuid when returning to view mode after editing a panel * support folderuid when preselecting the folderpicker in dashboard general settings * support folderuid when saving dashboard * support folderuid when pre-selecting folderpicker in dashboard form * support folderuid in routes when loading a dashboard * support folderuid when saving dashboard json * support folderuid when validating new dashboard name * support folderuid when moving dashboard to another folder * support folderuid on dashboard action buttons * support folderuid when creating a new dashboard on an empty folder * support folderuid when showing library panel modal * support folderuid when saving library panel * support folderuid when importing dashboard * fixed broken tests * use folderuid when importing dashboards * remove commented line * fix typo when comparing uid values --- .betterer.results | 17 ++-- .../components/Select/FolderPicker.test.tsx | 52 +++++------ .../core/components/Select/FolderPicker.tsx | 86 +++++++++---------- .../rule-editor/RuleFolderPicker.tsx | 4 +- .../DashboardPrompt/DashboardPrompt.tsx | 2 +- .../DashboardSettings/GeneralSettings.tsx | 6 +- .../components/PanelEditor/PanelEditor.tsx | 2 +- .../forms/SaveDashboardAsForm.tsx | 10 +-- .../components/SaveDashboard/types.ts | 4 +- .../SaveDashboard/useDashboardSave.tsx | 8 +- .../ShareModal/ShareLibraryPanel.tsx | 6 +- .../dashboard/containers/DashboardPage.tsx | 4 +- .../dashboard/services/DashboardSrv.ts | 2 +- .../features/dashboard/state/initDashboard.ts | 25 ++++-- public/app/features/dashboard/utils/panel.ts | 2 +- .../AddLibraryPanelModal.tsx | 22 ++--- .../LibraryPanelsSearch.test.tsx | 12 +-- .../LibraryPanelsView/reducer.test.ts | 4 +- .../PanelLibraryOptionsGroup.tsx | 2 +- .../SaveLibraryPanelModal.tsx | 13 ++- .../app/features/library-panels/state/api.ts | 4 +- public/app/features/library-panels/types.ts | 2 +- public/app/features/library-panels/utils.ts | 8 +- .../library-panels/utils/usePanelSave.ts | 4 +- .../components/ImportDashboardForm.tsx | 8 +- .../components/ImportDashboardOverview.tsx | 4 +- .../services/ValidationSrv.ts | 4 +- .../manage-dashboards/state/actions.test.ts | 4 +- .../manage-dashboards/state/actions.ts | 14 ++- .../manage-dashboards/state/reducers.ts | 2 +- .../manage-dashboards/utils/validation.ts | 4 +- .../search/components/DashboardActions.tsx | 10 +-- .../search/components/ManageDashboardsNew.tsx | 8 +- .../page/components/MoveToFolderModal.tsx | 2 +- .../search/page/components/SearchView.tsx | 2 +- public/app/plugins/panel/alertlist/module.tsx | 2 +- public/app/types/folders.ts | 2 +- 37 files changed, 194 insertions(+), 173 deletions(-) diff --git a/.betterer.results b/.betterer.results index 9b47eddaeb4..b09b00781de 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3227,12 +3227,11 @@ exports[`better eslint`] = { ], "public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -3266,8 +3265,7 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "2"] ], "public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4622,8 +4620,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/search/page/components/MoveToFolderModal.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/search/page/components/SearchResultsCards.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/public/app/core/components/Select/FolderPicker.test.tsx b/public/app/core/components/Select/FolderPicker.test.tsx index 6fa19a5a935..3c6b18fc135 100644 --- a/public/app/core/components/Select/FolderPicker.test.tsx +++ b/public/app/core/components/Select/FolderPicker.test.tsx @@ -16,8 +16,8 @@ describe('FolderPicker', () => { jest .spyOn(api, 'searchFolders') .mockResolvedValue([ - { title: 'Dash 1', id: 1 } as DashboardSearchHit, - { title: 'Dash 2', id: 2 } as DashboardSearchHit, + { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, + { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, ]); render(); @@ -28,12 +28,12 @@ describe('FolderPicker', () => { jest .spyOn(api, 'searchFolders') .mockResolvedValue([ - { title: 'Dash 1', id: 1 } as DashboardSearchHit, - { title: 'Dash 2', id: 2 } as DashboardSearchHit, - { title: 'Dash 3', id: 3 } as DashboardSearchHit, + { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, + { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, + { title: 'Dash 3', uid: '7MeksYbmk' } as DashboardSearchHit, ]); - render( hits.filter((h) => h.id !== 2)} />); + render( hits.filter((h) => h.uid !== 'wfTJJL5Wz')} />); const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input); selectEvent.openMenu(pickerContainer); @@ -46,13 +46,13 @@ describe('FolderPicker', () => { }); it('should allow creating a new option', async () => { - const newFolder = { title: 'New Folder', id: 3 } as DashboardSearchHit; + const newFolder = { title: 'New Folder', uid: '7MeksYbmk' } as DashboardSearchHit; jest .spyOn(api, 'searchFolders') .mockResolvedValue([ - { title: 'Dash 1', id: 1 } as DashboardSearchHit, - { title: 'Dash 2', id: 2 } as DashboardSearchHit, + { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, + { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, ]); const onChangeFn = jest.fn(); @@ -70,7 +70,7 @@ describe('FolderPicker', () => { expect(create).toHaveBeenCalledWith({ title: newFolder.title }); }); - expect(onChangeFn).toHaveBeenCalledWith({ title: newFolder.title, id: newFolder.id }); + expect(onChangeFn).toHaveBeenCalledWith({ title: newFolder.title, uid: newFolder.uid }); await waitFor(() => { expect(screen.getByText(newFolder.title)).toBeInTheDocument(); }); @@ -80,8 +80,8 @@ describe('FolderPicker', () => { jest .spyOn(api, 'searchFolders') .mockResolvedValue([ - { title: 'Dash 1', id: 1 } as DashboardSearchHit, - { title: 'Dash 2', id: 2 } as DashboardSearchHit, + { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, + { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, ]); jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true); @@ -101,8 +101,8 @@ describe('FolderPicker', () => { jest .spyOn(api, 'searchFolders') .mockResolvedValue([ - { title: 'Dash 1', id: 1 } as DashboardSearchHit, - { title: 'Dash 2', id: 2 } as DashboardSearchHit, + { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, + { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, ]); jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true); @@ -122,8 +122,8 @@ describe('FolderPicker', () => { jest .spyOn(api, 'searchFolders') .mockResolvedValue([ - { title: 'Dash 1', id: 1 } as DashboardSearchHit, - { title: 'Dash 2', id: 2 } as DashboardSearchHit, + { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, + { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, ]); jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false); @@ -141,28 +141,28 @@ describe('FolderPicker', () => { }); describe('getInitialValues', () => { - describe('when called with folderId and title', () => { - it('then it should return folderId and title', async () => { + describe('when called with folderUid and title', () => { + it('then it should return folderUid and title', async () => { const getFolder = jest.fn().mockResolvedValue({}); - const folder = await getInitialValues({ folderId: 0, folderName: 'Some title', getFolder }); + const folder = await getInitialValues({ folderUid: '', folderName: 'Some title', getFolder }); - expect(folder).toEqual({ label: 'Some title', value: 0 }); + expect(folder).toEqual({ label: 'Some title', value: '' }); expect(getFolder).not.toHaveBeenCalled(); }); }); - describe('when called with just a folderId', () => { + describe('when called with just a folderUid', () => { it('then it should call api to retrieve title', async () => { - const getFolder = jest.fn().mockResolvedValue({ id: 0, title: 'Title from api' }); - const folder = await getInitialValues({ folderId: 0, getFolder }); + const getFolder = jest.fn().mockResolvedValue({ uid: '', title: 'Title from api' }); + const folder = await getInitialValues({ folderUid: '', getFolder }); - expect(folder).toEqual({ label: 'Title from api', value: 0 }); + expect(folder).toEqual({ label: 'Title from api', value: '' }); expect(getFolder).toHaveBeenCalledTimes(1); - expect(getFolder).toHaveBeenCalledWith(0); + expect(getFolder).toHaveBeenCalledWith(''); }); }); - describe('when called without folderId', () => { + describe('when called without folderUid', () => { it('then it should throw an error', async () => { const getFolder = jest.fn().mockResolvedValue({}); await expect(getInitialValues({ getFolder })).rejects.toThrow(); diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index 9e7a8976314..8f2d2764b62 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -9,7 +9,7 @@ import { useStyles2, ActionMeta, AsyncSelect, Input, InputActionMeta } from '@gr import appEvents from 'app/core/app_events'; import { t } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; -import { createFolder, getFolderById, searchFolders } from 'app/features/manage-dashboards/state/actions'; +import { createFolder, getFolderByUid, searchFolders } from 'app/features/manage-dashboards/state/actions'; import { DashboardSearchHit } from 'app/features/search/types'; import { AccessControlAction, PermissionLevelString } from 'app/types'; @@ -28,13 +28,13 @@ export interface CustomAdd { } export interface Props { - onChange: ($folder: { title: string; id: number }) => void; + onChange: ($folder: { title: string; uid: string }) => void; enableCreateNew?: boolean; rootName?: string; enableReset?: boolean; dashboardId?: number | string; initialTitle?: string; - initialFolderId?: number; + initialFolderUid?: string; permissionLevel?: Exclude; filter?: FolderPickerFilter; allowEmpty?: boolean; @@ -47,15 +47,15 @@ export interface Props { /** * Skips loading all folders in order to find the folder matching * the folder where the dashboard is stored. - * Instead initialFolderId and initialTitle will be used to display the correct folder. - * initialFolderId needs to have an value > -1 or an error will be thrown. + * Instead initialFolderUid and initialTitle will be used to display the correct folder. + * initialFolderUid needs to be a string or an error will be thrown. */ skipInitialLoad?: boolean; /** The id of the search input. Use this to set a matching label with htmlFor */ inputId?: string; } -export type SelectedFolder = SelectableValue; -const VALUE_FOR_ADD = -10; +export type SelectedFolder = SelectableValue; +const VALUE_FOR_ADD = '-10'; export function FolderPicker(props: Props) { const { @@ -67,7 +67,7 @@ export function FolderPicker(props: Props) { inputId, onClear, enableReset, - initialFolderId, + initialFolderUid, initialTitle = '', permissionLevel = PermissionLevelString.Edit, rootName = 'General', @@ -90,14 +90,14 @@ export function FolderPicker(props: Props) { const getOptions = useCallback( async (query: string) => { const searchHits = await searchFolders(query, permissionLevel, accessControlMetadata); - const options: Array> = mapSearchHitsToOptions(searchHits, filter); + const options: Array> = mapSearchHitsToOptions(searchHits, filter); const hasAccess = contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor) || contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor); if (hasAccess && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { - options.unshift({ label: rootName, value: 0 }); + options.unshift({ label: rootName, value: '' }); } if ( @@ -106,7 +106,7 @@ export function FolderPicker(props: Props) { initialTitle !== '' && !options.find((option) => option.label === initialTitle) ) { - options.unshift({ label: initialTitle, value: initialFolderId }); + options.unshift({ label: initialTitle, value: initialFolderUid }); } if (enableCreateNew && Boolean(customAdd)) { return [...options, { value: VALUE_FOR_ADD, label: ADD_NEW_FOLER_OPTION, title: query }]; @@ -116,7 +116,7 @@ export function FolderPicker(props: Props) { }, [ enableReset, - initialFolderId, + initialFolderUid, initialTitle, permissionLevel, rootName, @@ -133,19 +133,19 @@ export function FolderPicker(props: Props) { }, [getOptions]); const loadInitialValue = async () => { - const resetFolder: SelectableValue = { label: initialTitle, value: undefined }; - const rootFolder: SelectableValue = { label: rootName, value: 0 }; + const resetFolder: SelectableValue = { label: initialTitle, value: undefined }; + const rootFolder: SelectableValue = { label: rootName, value: '' }; const options = await getOptions(''); - let folder: SelectableValue | null = null; + let folder: SelectableValue | null = null; - if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) { - folder = options.find((option) => option.value === initialFolderId) || null; + if (initialFolderUid !== undefined && initialFolderUid !== null) { + folder = options.find((option) => option.value === initialFolderUid) || null; } else if (enableReset && initialTitle) { folder = resetFolder; - } else if (initialFolderId) { - folder = options.find((option) => option.id === initialFolderId) || null; + } else if (initialFolderUid) { + folder = options.find((option) => option.id === initialFolderUid) || null; } if (!folder && !allowEmpty) { @@ -166,25 +166,25 @@ export function FolderPicker(props: Props) { useEffect(() => { // if this is not the same as our initial value notify parent - if (folder && folder.value !== initialFolderId) { - !isCreatingNew && folder.value && folder.label && onChange({ id: folder.value, title: folder.label }); + if (folder && folder.value !== initialFolderUid) { + !isCreatingNew && folder.value && folder.label && onChange({ uid: folder.value, title: folder.label }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [folder, initialFolderId]); + }, [folder, initialFolderUid]); // initial values for dropdown useAsync(async () => { if (skipInitialLoad) { const folder = await getInitialValues({ - getFolder: getFolderById, - folderId: initialFolderId, + getFolder: getFolderByUid, + folderUid: initialFolderUid, folderName: initialTitle, }); setFolder(folder); } await loadInitialValue(); - }, [skipInitialLoad, initialFolderId, initialTitle]); + }, [skipInitialLoad, initialFolderUid, initialTitle]); useEffect(() => { if (folder && folder.id === VALUE_FOR_ADD) { @@ -193,7 +193,7 @@ export function FolderPicker(props: Props) { }, [folder]); const onFolderChange = useCallback( - (newFolder: SelectableValue | null | undefined, actionMeta: ActionMeta) => { + (newFolder: SelectableValue | null | undefined, actionMeta: ActionMeta) => { if (newFolder?.value === VALUE_FOR_ADD) { setFolder({ id: VALUE_FOR_ADD, @@ -202,7 +202,7 @@ export function FolderPicker(props: Props) { setNewFolderValue(inputValue); } else { if (!newFolder) { - newFolder = { value: 0, label: rootName }; + newFolder = { value: '', label: rootName }; } if (actionMeta.action === 'clear' && onClear) { @@ -211,7 +211,7 @@ export function FolderPicker(props: Props) { } setFolder(newFolder); - onChange({ id: newFolder.value!, title: newFolder.label! }); + onChange({ uid: newFolder.value!, title: newFolder.label! }); } }, [onChange, onClear, rootName, inputValue] @@ -223,11 +223,11 @@ export function FolderPicker(props: Props) { return false; } const newFolder = await createFolder({ title: folderName }); - let folder: SelectableValue = { value: -1, label: 'Not created' }; + let folder: SelectableValue = { value: '', label: 'Not created' }; - if (newFolder.id > -1) { + if (newFolder.uid) { appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); - folder = { value: newFolder.id, label: newFolder.title }; + folder = { value: newFolder.uid, label: newFolder.title }; setFolder(newFolder); onFolderChange(folder, { action: 'create-option', option: folder }); @@ -255,7 +255,7 @@ export function FolderPicker(props: Props) { break; } case 'Escape': { - setFolder({ value: 0, label: rootName }); + setFolder({ value: '', label: rootName }); setIsCreatingNew(false); } } @@ -266,11 +266,11 @@ export function FolderPicker(props: Props) { const onNewFolderChange = (e: FormEvent) => { const value = e.currentTarget.value; setNewFolderValue(value); - setFolder({ id: -1, title: value }); + setFolder({ id: undefined, title: value }); }; const onBlur = () => { - setFolder({ value: 0, label: rootName }); + setFolder({ value: '', label: rootName }); setIsCreatingNew(false); }; @@ -344,25 +344,25 @@ export function FolderPicker(props: Props) { function mapSearchHitsToOptions(hits: DashboardSearchHit[], filter?: FolderPickerFilter) { const filteredHits = filter ? filter(hits) : hits; - return filteredHits.map((hit) => ({ label: hit.title, value: hit.id })); + return filteredHits.map((hit) => ({ label: hit.title, value: hit.uid })); } interface Args { - getFolder: typeof getFolderById; - folderId?: number; + getFolder: typeof getFolderByUid; + folderUid?: string; folderName?: string; } -export async function getInitialValues({ folderName, folderId, getFolder }: Args): Promise> { - if (folderId === null || folderId === undefined || folderId < 0) { - throw new Error('folderId should to be greater or equal to zero.'); +export async function getInitialValues({ folderName, folderUid, getFolder }: Args): Promise> { + if (folderUid === null || folderUid === undefined) { + throw new Error('folderUid is not found.'); } if (folderName) { - return { label: folderName, value: folderId }; + return { label: folderName, value: folderUid }; } - const folderDto = await getFolder(folderId); - return { label: folderDto.title, value: folderId }; + const folderDto = await getFolder(folderUid); + return { label: folderDto.title, value: folderUid }; } const getStyles = (theme: GrafanaTheme2) => ({ diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx index db6f02bd74c..f1e973a5d81 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx @@ -11,7 +11,7 @@ import { FolderWarning, CustomAdd } from '../../../../../core/components/Select/ export interface Folder { title: string; - id: number; + uid: string; } export interface RuleFolderPickerProps extends Omit { @@ -53,7 +53,7 @@ export function RuleFolderPicker(props: RuleFolderPickerProps) { showRoot={false} allowEmpty={true} initialTitle={value?.title} - initialFolderId={value?.id} + initialFolderUid={value?.uid} accessControlMetadata {...props} permissionLevel={PermissionLevelString.View} diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx index ab14b47bc27..0ae88841c4e 100644 --- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx +++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx @@ -76,7 +76,7 @@ export const DashboardPrompt = React.memo(({ dashboard }: Props) => { showModal(SaveLibraryPanelModal, { isUnsavedPrompt: true, panel: dashboard.panelInEdit as PanelModelWithLibraryPanel, - folderId: dashboard.meta.folderId as number, + folderUid: dashboard.meta.folderUid ?? '', onConfirm: () => { hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 789b60b2ffc..c84d5c8a6f4 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -30,8 +30,8 @@ export function GeneralSettingsUnconnected({ }: Props): JSX.Element { const [renderCounter, setRenderCounter] = useState(0); - const onFolderChange = (folder: { id: number; title: string }) => { - dashboard.meta.folderId = folder.id; + const onFolderChange = (folder: { uid: string; title: string }) => { + dashboard.meta.folderUid = folder.uid; dashboard.meta.folderTitle = folder.title; dashboard.meta.hasUnsavedFolderChange = true; }; @@ -109,7 +109,7 @@ export function GeneralSettingsUnconnected({ { {this.state.showSaveLibraryPanelModal && ( = ({ const defaultValues: SaveDashboardAsFormDTO = { title: isNew ? dashboard.title : `${dashboard.title} Copy`, $folder: { - id: dashboard.meta.folderId, + uid: dashboard.meta.folderUid, title: dashboard.meta.folderTitle, }, copyTags: false, @@ -60,7 +60,7 @@ export const SaveDashboardAsForm: React.FC = ({ return 'Dashboard name cannot be the same as folder name'; } try { - await validationSrv.validateNewDashboardName(getFormValues().$folder.id, dashboardName); + await validationSrv.validateNewDashboardName(getFormValues().$folder.uid, dashboardName); return true; } catch (e) { return e instanceof Error ? e.message : 'Dashboard name is invalid'; @@ -84,7 +84,7 @@ export const SaveDashboardAsForm: React.FC = ({ const result = await onSubmit( clone, { - folderId: data.$folder.id, + folderUid: data.$folder.uid, }, dashboard ); @@ -111,7 +111,7 @@ export const SaveDashboardAsForm: React.FC = ({ diff --git a/public/app/features/dashboard/components/SaveDashboard/types.ts b/public/app/features/dashboard/components/SaveDashboard/types.ts index 0a5646aa095..084ff68b517 100644 --- a/public/app/features/dashboard/components/SaveDashboard/types.ts +++ b/public/app/features/dashboard/components/SaveDashboard/types.ts @@ -11,7 +11,7 @@ export interface SaveDashboardData { } export interface SaveDashboardOptions extends CloneOptions { - folderId?: number; + folderUid?: string; overwrite?: boolean; message?: string; makeEditable?: boolean; @@ -20,7 +20,7 @@ export interface SaveDashboardOptions extends CloneOptions { export interface SaveDashboardCommand { dashboard: DashboardDataDTO; message?: string; - folderId?: number; + folderUid?: string; overwrite?: boolean; } diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index 22de9223b8e..42306bcd8c8 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -15,12 +15,12 @@ import { DashboardSavedEvent } from 'app/types/events'; import { SaveDashboardOptions } from './types'; const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { - let folderId = options.folderId; - if (folderId === undefined) { - folderId = dashboard.meta.folderId ?? saveModel.folderId; + let folderUid = options.folderUid; + if (folderUid === undefined) { + folderUid = dashboard.meta.folderUid ?? saveModel.folderUid; } - const result = await saveDashboardApiCall({ ...options, folderId, dashboard: saveModel }); + const result = await saveDashboardApiCall({ ...options, folderUid, dashboard: saveModel }); // fetch updated access control permissions await contextSrv.fetchUserPermissions(); return result; diff --git a/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx b/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx index 28f98cec05a..e3b65aab155 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareLibraryPanel.tsx @@ -7,10 +7,10 @@ import { AddLibraryPanelContents } from 'app/features/library-panels/components/ import { ShareModalTabProps } from './types'; interface Props extends ShareModalTabProps { - initialFolderId?: number; + initialFolderUid?: string; } -export const ShareLibraryPanel = ({ panel, initialFolderId, onDismiss }: Props) => { +export const ShareLibraryPanel = ({ panel, initialFolderUid, onDismiss }: Props) => { useEffect(() => { reportInteraction('grafana_dashboards_library_panel_share_viewed'); }, []); @@ -24,7 +24,7 @@ export const ShareLibraryPanel = ({ panel, initialFolderId, onDismiss }: Props)

Create library panel.

- + ); }; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 40c6b6100d8..1783d7d34bd 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -45,7 +45,7 @@ export interface DashboardPageRouteParams { export type DashboardPageRouteSearchParams = { tab?: string; - folderId?: string; + folderUid?: string; editPanel?: string; viewPanel?: string; editview?: string; @@ -139,7 +139,7 @@ export class UnthemedDashboardPage extends PureComponent { urlSlug: match.params.slug, urlUid: match.params.uid, urlType: match.params.type, - urlFolderId: queryParams.folderId, + urlFolderUid: queryParams.folderUid, panelType: queryParams.panelType, routeName: this.props.route.routeName, fixUrl: !isPublic, diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 0b4eb8d0f07..11e32a26eeb 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -69,7 +69,7 @@ export class DashboardSrv { const parsedJson = JSON.parse(json); return saveDashboard({ dashboard: parsedJson, - folderId: this.dashboard?.meta.folderId || parsedJson.folderId, + folderUid: this.dashboard?.meta.folderUid || parsedJson.folderUid, }); } diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index dc6d007d023..705dd42141c 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -12,7 +12,15 @@ import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { toStateKey } from 'app/features/variables/utils'; -import { DashboardDTO, DashboardInitPhase, DashboardRoutes, StoreState, ThunkDispatch, ThunkResult } from 'app/types'; +import { + DashboardDTO, + DashboardInitPhase, + DashboardMeta, + DashboardRoutes, + StoreState, + ThunkDispatch, + ThunkResult, +} from 'app/types'; import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { initVariablesTransaction } from '../../variables/state/actions'; @@ -27,7 +35,7 @@ export interface InitDashboardArgs { urlUid?: string; urlSlug?: string; urlType?: string; - urlFolderId?: string; + urlFolderUid?: string; panelType?: string; accessToken?: string; routeName?: string; @@ -89,7 +97,7 @@ async function fetchDashboard( return dashDTO; } case DashboardRoutes.New: { - return getNewDashboardModelData(args.urlFolderId, args.panelType); + return getNewDashboardModelData(args.urlFolderUid, args.panelType); } case DashboardRoutes.Path: { const path = args.urlSlug ?? ''; @@ -255,14 +263,17 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { }; } -export function getNewDashboardModelData(urlFolderId?: string, panelType?: string): any { +export function getNewDashboardModelData( + urlFolderUid?: string, + panelType?: string +): { dashboard: any; meta: DashboardMeta } { const data = { meta: { canStar: false, canShare: false, canDelete: false, isNew: true, - folderId: 0, + folderUid: '', }, dashboard: { title: 'New dashboard', @@ -276,8 +287,8 @@ export function getNewDashboardModelData(urlFolderId?: string, panelType?: strin }, }; - if (urlFolderId) { - data.meta.folderId = parseInt(urlFolderId, 10); + if (urlFolderUid) { + data.meta.folderUid = urlFolderUid; } return data; diff --git a/public/app/features/dashboard/utils/panel.ts b/public/app/features/dashboard/utils/panel.ts index 6950dadc9d4..1a870ee6b03 100644 --- a/public/app/features/dashboard/utils/panel.ts +++ b/public/app/features/dashboard/utils/panel.ts @@ -75,7 +75,7 @@ export const addLibraryPanel = (dashboard: DashboardModel, panel: PanelModel) => component: AddLibraryPanelModal, props: { panel, - initialFolderId: dashboard.meta.folderId, + initialFolderUid: dashboard.meta.folderUid, isOpen: true, }, }) diff --git a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx index 36d64f498da..9f2f5253f2b 100644 --- a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx @@ -13,11 +13,11 @@ import { usePanelSave } from '../../utils/usePanelSave'; interface AddLibraryPanelContentsProps { onDismiss: () => void; panel: PanelModel; - initialFolderId?: number; + initialFolderUid?: string; } -export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: AddLibraryPanelContentsProps) => { - const [folderId, setFolderId] = useState(initialFolderId); +export const AddLibraryPanelContents = ({ panel, initialFolderUid, onDismiss }: AddLibraryPanelContentsProps) => { + const [folderUid, setFolderUid] = useState(initialFolderUid); const [panelName, setPanelName] = useState(panel.title); const [debouncedPanelName, setDebouncedPanelName] = useState(panel.title); const [waiting, setWaiting] = useState(false); @@ -28,15 +28,15 @@ export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: A const { saveLibraryPanel } = usePanelSave(); const onCreate = useCallback(() => { panel.libraryPanel = { uid: '', name: panelName }; - saveLibraryPanel(panel, folderId!).then((res) => { + saveLibraryPanel(panel, folderUid!).then((res) => { if (!(res instanceof Error)) { onDismiss(); } }); - }, [panel, panelName, folderId, onDismiss, saveLibraryPanel]); + }, [panel, panelName, folderUid, onDismiss, saveLibraryPanel]); const isValidName = useAsync(async () => { try { - return !(await getLibraryPanelByName(panelName)).some((lp) => lp.folderId === folderId); + return !(await getLibraryPanelByName(panelName)).some((lp) => lp.folderUid === folderUid); } catch (err) { if (isFetchError(err)) { err.isHandled = true; @@ -45,7 +45,7 @@ export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: A } finally { setWaiting(false); } - }, [debouncedPanelName, folderId]); + }, [debouncedPanelName, folderUid]); const invalidInput = !isValidName?.value && isValidName.value !== undefined && panelName === debouncedPanelName && !waiting; @@ -72,8 +72,8 @@ export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: A )} > setFolderId(id)} - initialFolderId={initialFolderId} + onChange={({ uid }) => setFolderUid(uid)} + initialFolderUid={initialFolderUid} inputId="share-panel-library-panel-folder-picker" /> @@ -94,10 +94,10 @@ interface Props extends AddLibraryPanelContentsProps { isOpen?: boolean; } -export const AddLibraryPanelModal = ({ isOpen = false, panel, initialFolderId, ...props }: Props) => { +export const AddLibraryPanelModal = ({ isOpen = false, panel, initialFolderUid, ...props }: Props) => { return ( - + ); }; diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index e3a84f0a57e..726957acb8c 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -28,9 +28,9 @@ jest.mock('debounce-promise', () => { const debounce = (fn: any) => { const debounced = () => Promise.resolve([ - { label: 'General', value: { id: 0, title: 'General' } }, - { label: 'Folder1', value: { id: 1, title: 'Folder1' } }, - { label: 'Folder2', value: { id: 2, title: 'Folder2' } }, + { label: 'General', value: { uid: '', title: 'General' } }, + { label: 'Folder1', value: { id: 'xMsQdBfWz', title: 'Folder1' } }, + { label: 'Folder2', value: { id: 'wfTJJL5Wz', title: 'Folder2' } }, ]); return debounced; }; @@ -187,7 +187,7 @@ describe('LibraryPanelsSearch', () => { kind: LibraryElementKind.Panel, uid: 'uid', description: 'Library Panel Description', - folderId: 0, + folderUid: '', model: { type: 'timeseries', title: 'A title' }, type: 'timeseries', orgId: 1, @@ -242,7 +242,7 @@ describe('LibraryPanelsSearch', () => { kind: LibraryElementKind.Panel, uid: 'uid', description: 'Library Panel Description', - folderId: 0, + folderUid: '', model: { type: 'timeseries', title: 'A title' }, type: 'timeseries', orgId: 1, @@ -286,7 +286,7 @@ describe('LibraryPanelsSearch', () => { kind: LibraryElementKind.Panel, uid: 'uid', description: 'Library Panel Description', - folderId: 0, + folderUid: '', model: { type: 'timeseries', title: 'A title' }, type: 'timeseries', orgId: 1, diff --git a/public/app/features/library-panels/components/LibraryPanelsView/reducer.test.ts b/public/app/features/library-panels/components/LibraryPanelsView/reducer.test.ts index be4cbad2922..527d345534b 100644 --- a/public/app/features/library-panels/components/LibraryPanelsView/reducer.test.ts +++ b/public/app/features/library-panels/components/LibraryPanelsView/reducer.test.ts @@ -106,7 +106,7 @@ function mockLibraryPanel({ uid = '1', id = 1, orgId = 1, - folderId = 0, + folderUid = '', name = 'Test Panel', model = { type: 'text', title: 'Test Panel' }, meta = { @@ -126,7 +126,7 @@ function mockLibraryPanel({ uid, id, orgId, - folderId, + folderUid, name, kind: LibraryElementKind.Panel, model, diff --git a/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx b/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx index 3fb0c327952..94d86bab9a7 100644 --- a/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx +++ b/public/app/features/library-panels/components/PanelLibraryOptionsGroup/PanelLibraryOptionsGroup.tsx @@ -70,7 +70,7 @@ export const PanelLibraryOptionsGroup: FC = ({ panel, searchQuery }) => { setShowingAddPanelModal(false)} - initialFolderId={dashboard?.meta.folderId} + initialFolderUid={dashboard?.meta.folderUid} isOpen={showingAddPanelModal} /> )} diff --git a/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx b/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx index 442cce84952..e3b551d302a 100644 --- a/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal.tsx @@ -10,14 +10,21 @@ import { usePanelSave } from '../../utils/usePanelSave'; interface Props { panel: PanelModelWithLibraryPanel; - folderId: number; + folderUid: string; isUnsavedPrompt?: boolean; onConfirm: () => void; onDismiss: () => void; onDiscard: () => void; } -export const SaveLibraryPanelModal = ({ panel, folderId, isUnsavedPrompt, onDismiss, onConfirm, onDiscard }: Props) => { +export const SaveLibraryPanelModal = ({ + panel, + folderUid, + isUnsavedPrompt, + onDismiss, + onConfirm, + onDiscard, +}: Props) => { const [searchString, setSearchString] = useState(''); const dashState = useAsync(async () => { const searchHits = await getConnectedDashboards(panel.libraryPanel.uid); @@ -98,7 +105,7 @@ export const SaveLibraryPanelModal = ({ panel, folderId, isUnsavedPrompt, onDism )} - )} + {(isOpen) => + isSmallScreen ? ( + + ) : ( + + ) + } @@ -68,8 +65,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ }, }), separator: css({ - marginLeft: theme.spacing(1), - [theme.breakpoints.down('md')]: { + [theme.breakpoints.down('sm')]: { display: 'none', }, }), From 2a6ed76e543d03742b642e8e8c87af3461c4e2c8 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Thu, 17 Nov 2022 10:24:07 +0000 Subject: [PATCH 282/926] [Docs] Edit terraform example and doc for file generation (#58822) * docs: update terraform example and doc for file generation * docs: updated the documnettaion to include the help command with fields included * Update pkg/cmd/grafana-cli/commands/commands.go * docs: add help command --- pkg/cmd/grafana-cli/commands/commands.go | 24 +++++++++++++++++++ .../conflict_example_users.tf | 12 +++++----- .../commands/conflict_user_command.go | 6 +++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 44e4aab888d..0b7ef9461d7 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -201,6 +201,30 @@ var adminCommands = []*cli.Command{ { Name: "conflicts", Usage: "runs a conflict resolution to find users with multiple entries", + CustomHelpTemplate: ` +This command will find users with multiple entries in the database and try to resolve the conflicts. +explanation of each field: + +explanation of each field: +* email - the user’s email +* login - the user’s login/username +* last_seen_at - the user’s last login +* auth_module - if the user was created/signed in using an authentication provider +* conflict_email - a boolean if we consider the email to be a conflict +* conflict_login - a boolean if we consider the login to be a conflict + +# lists all the conflicting users +grafana-cli user-manager conflicts list + +# creates a conflict patch file to edit +grafana-cli user-manager conflicts generate-file + +# reads edited conflict patch file for validation +grafana-cli user-manager conflicts validate-file + +# validates and ingests edited patch file +grafana-cli user-manager conflicts ingest-file +`, Subcommands: []*cli.Command{ { Name: "list", diff --git a/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf b/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf index 8203ea8704c..cc0dc9489c5 100644 --- a/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf +++ b/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf @@ -16,7 +16,7 @@ provider "grafana" { // Creating the grafana-login resource "grafana_user" "grafana-login" { email = "grafana_login@grafana.com" - login = "GRAFANA_LOGIN@grafana.com" + login = "GRAFANA_LOGIN" password = "grafana_login@grafana.com" is_admin = false } @@ -24,7 +24,7 @@ resource "grafana_user" "grafana-login" { // Creating the grafana-login resource "grafana_user" "grafana-login-2" { email = "grafana_login_2@grafana.com" - login = "grafana_login@grafana.com" + login = "grafana_login" password = "grafana_login@grafana.com" is_admin = false } @@ -33,7 +33,7 @@ resource "grafana_user" "grafana-login-2" { // Creating the grafana-email resource "grafana_user" "grafana-email" { email = "grafana_email@grafana.com" - login = "grafana_email@grafana.com" + login = "user_login_a" password = "grafana_email@grafana.com" is_admin = false } @@ -41,7 +41,7 @@ resource "grafana_user" "grafana-email" { // Creating the grafana-email resource "grafana_user" "grafana-email-2" { email = "GRAFANA_EMAIL@grafana.com" - login = "grafana_email_2@grafana.com" + login = "user_login_b" password = "grafana_email@grafana.com" is_admin = false } @@ -50,7 +50,7 @@ resource "grafana_user" "grafana-email-2" { // Creating the grafana-user resource "grafana_user" "grafana-user" { email = "grafana_user@grafana.com" - login = "grafana_user@grafana.com" + login = "grafana_user" password = "grafana_user@grafana.com" is_admin = false } @@ -58,7 +58,7 @@ resource "grafana_user" "grafana-user" { // Creating the grafana-user resource "grafana_user" "grafana-user-2" { email = "GRAFANA_USER@grafana.com" - login = "GRAFANA_USER@grafana.com" + login = "GRAFANA_USER" password = "grafana_user@grafana.com" is_admin = false } diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index a1464e72e0b..ff647d66fc5 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -203,6 +203,12 @@ func getDocumentationForFile() string { # # If you feel like you want to wait with a specific block, # delete all lines regarding that conflict block. +# email - the user’s email +# login - the user’s login/username +# last_seen_at - the user’s last login +# auth_module - if the user was created/signed in using an authentication provider +# conflict_email - a boolean if we consider the email to be a conflict +# conflict_login - a boolean if we consider the login to be a conflict # ` } From d0dcbe34b2d7f23a2097a6eb8bbb9f0925922cd9 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Thu, 17 Nov 2022 12:44:53 +0200 Subject: [PATCH 283/926] grafana.com: Make `beta` and `test` releases not stable (#58883) If version is beta or test, don't mark it as stable --- pkg/build/cmd/grafanacom.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/build/cmd/grafanacom.go b/pkg/build/cmd/grafanacom.go index 9d81d8b62f9..200f9b40b3c 100644 --- a/pkg/build/cmd/grafanacom.go +++ b/pkg/build/cmd/grafanacom.go @@ -177,7 +177,7 @@ func publishPackages(cfg packaging.PublishConfig) error { Version: cfg.Version, ReleaseDate: time.Now().UTC(), Builds: builds, - Stable: cfg.ReleaseMode.Mode == config.TagMode, + Stable: cfg.ReleaseMode.Mode == config.TagMode && !cfg.ReleaseMode.IsBeta && !cfg.ReleaseMode.IsTest, Beta: cfg.ReleaseMode.IsBeta, Nightly: cfg.ReleaseMode.Mode == config.CronjobMode, } From 5ea077c44026709147a9992bde47aea0c5f9a9c0 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Thu, 17 Nov 2022 15:30:09 +0200 Subject: [PATCH 284/926] CI: Replace `TAG` with `DRONE_TAG` in CI (#58894) * Replace TAG with DRONE_TAG * Fix variable call * Replace remaining bits * Bump grabpl version --- .drone.yml | 92 +++++++++++++++---------------- scripts/drone/events/release.star | 6 +- scripts/drone/steps/lib.star | 4 +- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/.drone.yml b/.drone.yml index 3a72db10b15..103256ecf2f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -19,7 +19,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -73,7 +73,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -330,7 +330,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -660,7 +660,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -773,7 +773,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -886,7 +886,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -961,7 +961,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1205,7 +1205,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1633,7 +1633,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -1747,7 +1747,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -1924,7 +1924,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2227,7 +2227,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2374,7 +2374,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2480,7 +2480,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -2537,7 +2537,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2921,7 +2921,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -2986,7 +2986,7 @@ steps: name: clone-enterprise - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3111,7 +3111,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3262,7 +3262,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -3337,7 +3337,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3366,7 +3366,7 @@ steps: path: /var/run/docker.sock - commands: - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana --version-tag - ${TAG} + ${DRONE_TAG} depends_on: - fetch-images-oss environment: @@ -3383,7 +3383,7 @@ steps: path: /var/run/docker.sock - commands: - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-oss --version-tag - ${TAG} + ${DRONE_TAG} depends_on: - fetch-images-oss environment: @@ -3425,7 +3425,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3454,7 +3454,7 @@ steps: path: /var/run/docker.sock - commands: - ./bin/grabpl artifacts docker publish --dockerhub-repo grafana/grafana-enterprise - --version-tag ${TAG} + --version-tag ${DRONE_TAG} depends_on: - fetch-images-enterprise environment: @@ -3496,7 +3496,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3525,7 +3525,7 @@ steps: path: /var/run/docker.sock - commands: - ./bin/grabpl artifacts docker publish --security --dockerhub-repo grafana/grafana-enterprise - --version-tag ${TAG} + --version-tag ${DRONE_TAG} depends_on: - fetch-images-enterprise environment: @@ -3567,12 +3567,12 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - - ./bin/grabpl artifacts publish --security --tag ${TAG} --src-bucket $${PRERELEASE_BUCKET} + - ./bin/grabpl artifacts publish --security --tag $${DRONE_TAG} --src-bucket $${PRERELEASE_BUCKET} depends_on: - grabpl environment: @@ -3609,12 +3609,12 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl - commands: - - ./bin/grabpl artifacts publish --tag ${TAG} --src-bucket $${PRERELEASE_BUCKET} + - ./bin/grabpl artifacts publish --tag $${DRONE_TAG} --src-bucket $${PRERELEASE_BUCKET} depends_on: - grabpl environment: @@ -3651,7 +3651,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3661,7 +3661,7 @@ steps: image: grafana/build-container:1.6.4 name: yarn-install - commands: - - ./bin/grabpl artifacts npm retrieve --tag v${TAG} + - ./bin/grabpl artifacts npm retrieve --tag ${DRONE_TAG} depends_on: - yarn-install environment: @@ -3673,7 +3673,7 @@ steps: image: grafana/grafana-ci-deploy:1.3.3 name: retrieve-npm-packages - commands: - - ./bin/grabpl artifacts npm release --tag v${TAG} + - ./bin/grabpl artifacts npm release --tag ${DRONE_TAG} depends_on: - retrieve-npm-packages environment: @@ -3712,7 +3712,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3807,7 +3807,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3899,7 +3899,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -3944,7 +3944,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4221,7 +4221,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4362,7 +4362,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4465,7 +4465,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/windows/grabpl.exe -OutFile grabpl.exe image: grafana/ci-wix:0.1.1 name: windows-init @@ -4515,7 +4515,7 @@ services: [] steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4892,7 +4892,7 @@ steps: name: identify-runner - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -4954,7 +4954,7 @@ steps: name: clone-enterprise - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -5075,7 +5075,7 @@ services: steps: - commands: - mkdir -p bin - - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/grabpl + - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/grabpl - chmod +x bin/grabpl image: byrnedo/alpine-curl:0.1.8 name: grabpl @@ -5222,7 +5222,7 @@ steps: name: identify-runner - commands: - $$ProgressPreference = "SilentlyContinue" - - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.16/windows/grabpl.exe + - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.17/windows/grabpl.exe -OutFile grabpl.exe - git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git" - cd grafana-enterprise @@ -5512,6 +5512,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 2b1b5ade4e8007a9d5b76ec2dbc9e647ca0938e00713b3ad8e8163bce2db40a6 +hmac: 77ae647c9addfcd9966d462ca9967b85a87e18adfe0e9e0a2c6b1cf5d7f42493 ... diff --git a/scripts/drone/events/release.star b/scripts/drone/events/release.star index d3a9d35fe82..1339bfcba81 100644 --- a/scripts/drone/events/release.star +++ b/scripts/drone/events/release.star @@ -102,7 +102,7 @@ def retrieve_npm_packages_step(): 'PRERELEASE_BUCKET': from_secret(prerelease_bucket) }, 'commands': [ - './bin/grabpl artifacts npm retrieve --tag v${TAG}' + './bin/grabpl artifacts npm retrieve --tag ${DRONE_TAG}' ], } @@ -118,7 +118,7 @@ def release_npm_packages_step(): 'NPM_TOKEN': from_secret('npm_token'), }, 'commands': [ - './bin/grabpl artifacts npm release --tag v${TAG}' + './bin/grabpl artifacts npm release --tag ${DRONE_TAG}' ], } @@ -361,7 +361,7 @@ def publish_artifacts_step(mode): 'GCP_KEY': from_secret('gcp_key'), 'PRERELEASE_BUCKET': from_secret('prerelease_bucket'), }, - 'commands': ['./bin/grabpl artifacts publish {}--tag ${{TAG}} --src-bucket $${{PRERELEASE_BUCKET}}'.format(security)], + 'commands': ['./bin/grabpl artifacts publish {}--tag $${{DRONE_TAG}} --src-bucket $${{PRERELEASE_BUCKET}}'.format(security)], 'depends_on': ['grabpl'], } diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 6e80c0e9708..d185b6fe567 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,6 +1,6 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') -grabpl_version = 'v3.0.16' +grabpl_version = 'v3.0.17' build_image = 'grafana/build-container:1.6.4' publish_image = 'grafana/grafana-ci-deploy:1.3.3' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' @@ -848,7 +848,7 @@ def publish_images_step(edition, ver_mode, mode, docker_repo, trigger=None): if ver_mode == 'release': deps = ['fetch-images-{}'.format(edition)] - cmd += ' --version-tag ${TAG}' + cmd += ' --version-tag ${DRONE_TAG}' else: deps = ['build-docker-images', 'build-docker-images-ubuntu'] From 7e9d94cfda96f4229019d3a9ae1d854162595ae2 Mon Sep 17 00:00:00 2001 From: Jo Date: Thu, 17 Nov 2022 14:02:17 +0000 Subject: [PATCH 285/926] Chore: Extract server lock error so it can be used with errors.As (#58899) chore: extract server lock Error so it can be used with error.As --- pkg/infra/serverlock/errors.go | 9 +++++++++ pkg/infra/serverlock/serverlock.go | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 pkg/infra/serverlock/errors.go diff --git a/pkg/infra/serverlock/errors.go b/pkg/infra/serverlock/errors.go new file mode 100644 index 00000000000..f5fe91c68db --- /dev/null +++ b/pkg/infra/serverlock/errors.go @@ -0,0 +1,9 @@ +package serverlock + +type ServerLockExistsError struct { + actionName string +} + +func (e *ServerLockExistsError) Error() string { + return "there is already a lock for this actionName: " + e.actionName +} diff --git a/pkg/infra/serverlock/serverlock.go b/pkg/infra/serverlock/serverlock.go index 2b92dfdadb5..c595f1e19b7 100644 --- a/pkg/infra/serverlock/serverlock.go +++ b/pkg/infra/serverlock/serverlock.go @@ -2,7 +2,6 @@ package serverlock import ( "context" - "errors" "time" "go.opentelemetry.io/otel/attribute" @@ -185,7 +184,7 @@ func (sl *ServerLockService) acquireForRelease(ctx context.Context, actionName s if len(lockRows) > 0 { result := lockRows[0] if sl.isLockWithinInterval(result, maxInterval) { - return errors.New("there is already a lock for this actionName: " + actionName) + return &ServerLockExistsError{actionName: actionName} } else { // lock has timeouted, so we update the timestamp result.LastExecution = time.Now().Unix() From 6e3cb2e3edfe0c77820627d4e90bde8e4883f710 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 17 Nov 2022 15:03:15 +0100 Subject: [PATCH 286/926] Oauth: Remove unused function for oauth implementations (#58887) * Oauth: remove unused function * Oauth: remove unused Oauth types --- pkg/login/social/azuread_oauth.go | 5 ----- pkg/login/social/generic_oauth.go | 5 ----- pkg/login/social/github_oauth.go | 6 ------ pkg/login/social/gitlab_oauth.go | 6 ------ pkg/login/social/google_oauth.go | 6 ------ pkg/login/social/grafana_com_oauth.go | 5 ----- pkg/login/social/okta_oauth.go | 5 ----- pkg/login/social/social.go | 1 - pkg/models/models.go | 14 -------------- 9 files changed, 53 deletions(-) delete mode 100644 pkg/models/models.go diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go index 942530b8410..df6c94ca4eb 100644 --- a/pkg/login/social/azuread_oauth.go +++ b/pkg/login/social/azuread_oauth.go @@ -8,7 +8,6 @@ import ( "net/http" "strings" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" "golang.org/x/oauth2" @@ -46,10 +45,6 @@ type azureAccessClaims struct { TenantID string `json:"tid"` } -func (s *SocialAzureAD) Type() int { - return int(models.AZUREAD) -} - func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { idToken := token.Extra("id_token") if idToken == nil { diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index dd7b8376a69..69a47ccef22 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -13,7 +13,6 @@ import ( "regexp" "strconv" - "github.com/grafana/grafana/pkg/models" "golang.org/x/oauth2" ) @@ -32,10 +31,6 @@ type SocialGenericOAuth struct { teamIds []string } -func (s *SocialGenericOAuth) Type() int { - return int(models.GENERIC) -} - func (s *SocialGenericOAuth) IsTeamMember(client *http.Client) bool { if len(s.teamIds) == 0 { return true diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index 1636a5918ff..a43610f09af 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -7,8 +7,6 @@ import ( "net/http" "regexp" - "github.com/grafana/grafana/pkg/models" - "golang.org/x/oauth2" ) @@ -33,10 +31,6 @@ var ( ErrMissingOrganizationMembership = Error{"user not a member of one of the required organizations"} ) -func (s *SocialGithub) Type() int { - return int(models.GITHUB) -} - func (s *SocialGithub) IsTeamMember(client *http.Client) bool { if len(s.teamIds) == 0 { return true diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index abb12ed46a0..c7cd9d51d13 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -6,8 +6,6 @@ import ( "net/http" "regexp" - "github.com/grafana/grafana/pkg/models" - "golang.org/x/oauth2" ) @@ -17,10 +15,6 @@ type SocialGitlab struct { apiUrl string } -func (s *SocialGitlab) Type() int { - return int(models.GITLAB) -} - func (s *SocialGitlab) IsGroupMember(groups []string) bool { if len(s.allowedGroups) == 0 { return true diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go index e15834a45fb..0c0a1d256dd 100644 --- a/pkg/login/social/google_oauth.go +++ b/pkg/login/social/google_oauth.go @@ -5,8 +5,6 @@ import ( "fmt" "net/http" - "github.com/grafana/grafana/pkg/models" - "golang.org/x/oauth2" ) @@ -16,10 +14,6 @@ type SocialGoogle struct { apiUrl string } -func (s *SocialGoogle) Type() int { - return int(models.GOOGLE) -} - func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id string `json:"id"` diff --git a/pkg/login/social/grafana_com_oauth.go b/pkg/login/social/grafana_com_oauth.go index 95f08f29ed7..24391871645 100644 --- a/pkg/login/social/grafana_com_oauth.go +++ b/pkg/login/social/grafana_com_oauth.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" "golang.org/x/oauth2" @@ -21,10 +20,6 @@ type OrgRecord struct { Login string `json:"login"` } -func (s *SocialGrafanaCom) Type() int { - return int(models.GRAFANA_COM) -} - func (s *SocialGrafanaCom) IsEmailAllowed(email string) bool { return true } diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go index a7863518915..6eda8afab24 100644 --- a/pkg/login/social/okta_oauth.go +++ b/pkg/login/social/okta_oauth.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" - "github.com/grafana/grafana/pkg/models" "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2/jwt" ) @@ -44,10 +43,6 @@ func (claims *OktaClaims) extractEmail() string { return claims.Email } -func (s *SocialOkta) Type() int { - return int(models.OKTA) -} - func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { idToken := token.Extra("id_token") if idToken == nil { diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 788593f7fb4..217f2606a19 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -240,7 +240,6 @@ func (b *BasicUserInfo) String() string { } type SocialConnector interface { - Type() int UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) IsEmailAllowed(email string) bool IsSignupAllowed() bool diff --git a/pkg/models/models.go b/pkg/models/models.go deleted file mode 100644 index 777c5297b6c..00000000000 --- a/pkg/models/models.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -type OAuthType int - -const ( - GITHUB OAuthType = iota + 1 - GOOGLE - TWITTER - GENERIC - GRAFANA_COM - GITLAB - AZUREAD - OKTA -) From ac66e14054297c50785491b23dfaeb880cbfe091 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 17 Nov 2022 14:31:03 +0000 Subject: [PATCH 287/926] Navigation: rename Grafana Machine Learning to just Machine Learning (#58893) rename Grafana Machine Learning to just Machine Learning --- pkg/services/navtree/navtreeimpl/applinks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 9ac62c7cfd4..22c0aef5acf 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -254,7 +254,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2, Text: "Synthetics"}, "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"}, "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incident"}, - "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3}, + "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, } From c14cbfc65d2127ed5a2e8069aff377bfc3bbaef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 17 Nov 2022 15:51:09 +0100 Subject: [PATCH 288/926] Breadcrumbs: Remove semi-bold and change current/last breadcrumb text color (#58875) --- public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx b/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx index 45d6a565ad3..718a2fee452 100644 --- a/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx +++ b/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx @@ -44,8 +44,10 @@ const getStyles = (theme: GrafanaTheme2) => { overflow: 'hidden', padding: theme.spacing(0, 0.5), whiteSpace: 'nowrap', + color: theme.colors.text.secondary, }), breadcrumbLink: css({ + color: theme.colors.text.primary, '&:hover': { textDecoration: 'underline', }, @@ -55,7 +57,6 @@ const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.text.primary, display: 'flex', flex: 1, - fontWeight: theme.typography.fontWeightMedium, minWidth: 0, maxWidth: 'max-content', From c093a471e657998b37166b8517c114acc4816a64 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 17 Nov 2022 16:09:06 +0100 Subject: [PATCH 289/926] AppRootPage: Fix passing the queryParams (#58912) * fix(AppRootPage): push the query params properly * refactor: remove unnecessary changes in AppRootPage * refactor(AppRootPage): use existing utility function --- public/app/features/plugins/components/AppRootPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index 40e465cd1d9..0c19ca05dc9 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -2,10 +2,10 @@ import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; -import { useLocation, useRouteMatch, useParams } from 'react-router-dom'; +import { useLocation, useRouteMatch } from 'react-router-dom'; import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, locationSearchToObject } from '@grafana/runtime'; import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; @@ -35,13 +35,13 @@ const initialState: State = { loading: true, pluginNav: null, plugin: null }; export function AppRootPage({ pluginId, pluginNavSection }: Props) { const match = useRouteMatch(); - const queryParams = useParams(); const location = useLocation(); const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const portalNode = useMemo(() => createHtmlPortalNode(), []); const currentUrl = config.appSubUrl + location.pathname + location.search; const { plugin, loading, pluginNav } = state; const navModel = buildPluginSectionNav(pluginNavSection, pluginNav, currentUrl); + const queryParams = useMemo(() => locationSearchToObject(location.search), [location.search]); const context = useMemo(() => buildPluginPageContext(navModel), [navModel]); useEffect(() => { From 0c4aa6d0d8e363a7a1425dbaee943c745867e11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 17 Nov 2022 16:15:51 +0100 Subject: [PATCH 290/926] DashboardScene: First step to loading the current dashboard model and rendering it as a scene (#57012) * Initial dashboard loading start * loading dashboard works and shows something * loading dashboard works and shows something * Minor tweaks * Add starred dashboards to scene list page * Use new SceneGridLayout * Allow switching directly from dashboard to a scene * Migrate basic dashboard rows to scene based dashboard * Review nit Co-authored-by: Dominik Prokop --- .../dashboard/components/DashNav/DashNav.tsx | 10 + .../dashboard/services/DashboardLoaderSrv.ts | 2 +- public/app/features/scenes/SceneListPage.tsx | 35 +++- .../scenes/dashboard/DashboardScene.tsx | 44 +++++ .../scenes/dashboard/DashboardScenePage.tsx | 32 ++++ .../scenes/dashboard/DashboardsLoader.ts | 181 ++++++++++++++++++ public/app/routes/routes.tsx | 6 + 7 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 public/app/features/scenes/dashboard/DashboardScene.tsx create mode 100644 public/app/features/scenes/dashboard/DashboardScenePage.tsx create mode 100644 public/app/features/scenes/dashboard/DashboardsLoader.ts diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 2ada1fcc28d..0ced7a34b85 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -338,6 +338,16 @@ export const DashNav = React.memo((props) => { buttons.push(renderTimeControls()); buttons.push(tvButton); + + if (config.featureToggles.scenes) { + buttons.push( + locationService.push(`/scenes/dashboard/${dashboard.uid}`)} + /> + ); + } return buttons; }; diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index 986cec7d14d..5927eb975e1 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -32,7 +32,7 @@ export class DashboardLoaderSrv { }; } - loadDashboard(type: UrlQueryValue, slug: any, uid: any) { + loadDashboard(type: UrlQueryValue, slug: any, uid: any): Promise { let promise; if (type === 'script') { diff --git a/public/app/features/scenes/SceneListPage.tsx b/public/app/features/scenes/SceneListPage.tsx index 2cf1e6ad489..59f48c9ab7c 100644 --- a/public/app/features/scenes/SceneListPage.tsx +++ b/public/app/features/scenes/SceneListPage.tsx @@ -1,27 +1,48 @@ // Libraries import React, { FC } from 'react'; +import { useAsync } from 'react-use'; import { Stack } from '@grafana/experimental'; import { Card } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; // Types +import { getGrafanaSearcher } from '../search/service'; + import { getScenes } from './scenes'; export interface Props {} export const SceneListPage: FC = ({}) => { const scenes = getScenes(); + const results = useAsync(() => { + return getGrafanaSearcher().starred({ starred: true }); + }, []); return ( - + - - {scenes.map((scene) => ( - - {scene.state.title} - - ))} + +
Test scenes
+ + {scenes.map((scene) => ( + + {scene.state.title} + + ))} + + {results.value && ( + <> +
Starred dashboards
+ + {results.value!.view.map((dash) => ( + + {dash.name} + + ))} + + + )}
diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx new file mode 100644 index 00000000000..46106a06a85 --- /dev/null +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { PageLayoutType } from '@grafana/data'; +import { config, locationService } from '@grafana/runtime'; +import { PageToolbar, ToolbarButton } from '@grafana/ui'; +import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; +import { Page } from 'app/core/components/Page/Page'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneLayout, SceneObject, SceneObjectStatePlain } from '../core/types'; + +interface DashboardSceneState extends SceneObjectStatePlain { + title: string; + uid: string; + layout: SceneLayout; + actions?: SceneObject[]; +} + +export class DashboardScene extends SceneObjectBase { + public static Component = DashboardSceneRenderer; +} + +function DashboardSceneRenderer({ model }: SceneComponentProps) { + const { title, layout, actions = [], uid } = model.useState(); + + const toolbarActions = (actions ?? []).map((action) => ); + + toolbarActions.push( + locationService.push(`/d/${uid}`)} tooltip="View as Dashboard" /> + ); + const pageToolbar = config.featureToggles.topnav ? ( + + ) : ( + {toolbarActions} + ); + + return ( + +
+ +
+
+ ); +} diff --git a/public/app/features/scenes/dashboard/DashboardScenePage.tsx b/public/app/features/scenes/dashboard/DashboardScenePage.tsx new file mode 100644 index 00000000000..b806e608d4f --- /dev/null +++ b/public/app/features/scenes/dashboard/DashboardScenePage.tsx @@ -0,0 +1,32 @@ +// Libraries +import React, { FC, useEffect } from 'react'; + +import { Page } from 'app/core/components/Page/Page'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { getDashboardLoader } from './DashboardsLoader'; + +export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {} + +export const DashboardScenePage: FC = ({ match }) => { + const loader = getDashboardLoader(); + const { dashboard, isLoading } = loader.useState(); + + useEffect(() => { + loader.load(match.params.uid); + }, [loader, match.params.uid]); + + if (!dashboard) { + return ( + + {isLoading && } + {!isLoading &&

Dashboard not found

} +
+ ); + } + + return ; +}; + +export default DashboardScenePage; diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts new file mode 100644 index 00000000000..7d56b5a78d5 --- /dev/null +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -0,0 +1,181 @@ +import { getDefaultTimeRange } from '@grafana/data'; +import { StateManagerBase } from 'app/core/services/StateManagerBase'; +import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { DashboardDTO } from 'app/types'; + +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneObject } from '../core/types'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { DashboardScene } from './DashboardScene'; + +export interface DashboardLoaderState { + dashboard?: DashboardScene; + isLoading?: boolean; + loadError?: string; +} + +export class DashboardLoader extends StateManagerBase { + private cache: Record = {}; + + public async load(uid: string) { + const fromCache = this.cache[uid]; + if (fromCache) { + this.setState({ dashboard: fromCache }); + return; + } + + this.setState({ isLoading: true }); + + try { + const rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid); + + if (rsp.dashboard) { + this.initDashboard(rsp); + } else { + throw new Error('No dashboard returned'); + } + } catch (err) { + this.setState({ isLoading: false, loadError: String(err) }); + } + } + + private initDashboard(rsp: DashboardDTO) { + // Just to have migrations run + const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); + + const dashboard = new DashboardScene({ + title: oldModel.title, + uid: oldModel.uid, + layout: new SceneGridLayout({ + children: this.buildSceneObjectsFromDashboard(oldModel), + }), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + actions: [new SceneTimePicker({})], + }); + + this.cache[rsp.dashboard.uid] = dashboard; + this.setState({ dashboard, isLoading: false }); + } + + private buildSceneObjectsFromDashboard(dashboard: DashboardModel) { + // collects all panels and rows + const panels: SceneObject[] = []; + + // indicates expanded row that's currently processed + let currentRow: PanelModel | null = null; + // collects panels in the currently processed, expanded row + let currentRowPanels: SceneObject[] = []; + + for (const panel of dashboard.panels) { + if (panel.type === 'row') { + if (!currentRow) { + if (Boolean(panel.collapsed)) { + // collapsed rows contain their panels within the row model + panels.push( + new SceneGridRow({ + title: panel.title, + isCollapsed: true, + size: { + y: panel.gridPos.y, + }, + children: panel.panels + ? panel.panels.map( + (p) => + new VizPanel({ + title: p.title, + pluginId: p.type, + size: { + x: p.gridPos.x, + y: p.gridPos.y, + width: p.gridPos.w, + height: p.gridPos.h, + }, + options: p.options, + fieldConfig: p.fieldConfig, + $data: new SceneQueryRunner({ + queries: p.targets, + }), + }) + ) + : [], + }) + ); + } else { + // indicate new row to be processed + currentRow = panel; + } + } else { + // when a row has been processed, and we hit a next one for processing + if (currentRow.id !== panel.id) { + // commit previous row panels + panels.push( + new SceneGridRow({ + title: currentRow!.title, + size: { + y: currentRow.gridPos.y, + }, + children: currentRowPanels, + }) + ); + + currentRow = panel; + currentRowPanels = []; + } + } + } else { + const panelObject = new VizPanel({ + title: panel.title, + pluginId: panel.type, + size: { + x: panel.gridPos.x, + y: panel.gridPos.y, + width: panel.gridPos.w, + height: panel.gridPos.h, + }, + options: panel.options, + fieldConfig: panel.fieldConfig, + $data: new SceneQueryRunner({ + queries: panel.targets, + }), + }); + + // when processing an expanded row, collect its panels + if (currentRow) { + currentRowPanels.push(panelObject); + } else { + panels.push(panelObject); + } + } + } + + // commit a row if it's the last one + if (currentRow) { + panels.push( + new SceneGridRow({ + title: currentRow!.title, + size: { + y: currentRow.gridPos.y, + }, + children: currentRowPanels, + }) + ); + } + + return panels; + } +} + +let loader: DashboardLoader | null = null; + +export function getDashboardLoader(): DashboardLoader { + if (!loader) { + loader = new DashboardLoader({}); + } + + return loader; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 938a38b7e0d..17b5d38a063 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -523,6 +523,12 @@ export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] { path: '/scenes', component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/SceneListPage')), }, + { + path: '/scenes/dashboard/:uid', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/dashboard/DashboardScenePage') + ), + }, { path: '/scenes/:name', component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/ScenePage')), From 5cad7089b370f9efd21b7d2aa3435a8575963343 Mon Sep 17 00:00:00 2001 From: Kristina Date: Thu, 17 Nov 2022 09:27:07 -0600 Subject: [PATCH 291/926] Explore: Enable resize of split pane (#58683) * Move layout to paneleditor, make SplitPaneWrapper more generic * Read/write the size ratio in local storage * Add min height to enable scrollbar * Enable show/hide panel options * Add new component to explore * Add styles * Bring in code from other branch * Fix update size function, add min size to explore container * Add window size, save width as a ratio * Fix tests * Allow for one child * Remove children type definition * Use library methods for min/max size instead of hooks --- .../src/components/PageLayout/PageToolbar.tsx | 6 +- .../SplitPaneWrapper/SplitPaneWrapper.tsx | 41 ++++++++-- .../features/explore/ExplorePaneContainer.tsx | 13 ++-- .../app/features/explore/ExploreToolbar.tsx | 36 +++++++-- public/app/features/explore/Wrapper.test.tsx | 35 +++++++-- public/app/features/explore/Wrapper.tsx | 78 ++++++++++++++----- .../app/features/explore/state/main.test.ts | 6 ++ public/app/features/explore/state/main.ts | 45 +++++++++++ public/app/types/explore.ts | 15 ++++ 9 files changed, 231 insertions(+), 44 deletions(-) diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index 83e1f292643..b31470ae630 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -24,6 +24,7 @@ export interface Props { className?: string; isFullscreen?: boolean; 'aria-label'?: string; + buttonOverflowAlignment?: 'left' | 'right'; } /** @alpha */ @@ -42,6 +43,7 @@ export const PageToolbar: FC = React.memo( className, /** main nav-container aria-label **/ 'aria-label': ariaLabel, + buttonOverflowAlignment = 'right', }) => { const styles = useStyles2(getStyles); @@ -132,7 +134,9 @@ export const PageToolbar: FC = React.memo( )} - {React.Children.toArray(children).filter(Boolean)} + + {React.Children.toArray(children).filter(Boolean)} + ); } diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 51d1786d274..69816af915f 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -9,9 +9,11 @@ interface Props { splitOrientation?: Split; paneSize: number; splitVisible?: boolean; + minSize?: number; maxSize?: number; primary?: 'first' | 'second'; onDragFinished?: (size?: number) => void; + paneStyle?: React.CSSProperties; secondaryPaneStyle?: React.CSSProperties; } @@ -49,9 +51,27 @@ export class SplitPaneWrapper extends PureComponent { }; render() { - const { paneSize, splitOrientation, maxSize, primary, secondaryPaneStyle } = this.props; + const { + children, + paneSize, + splitOrientation, + maxSize, + minSize, + primary, + paneStyle, + secondaryPaneStyle, + splitVisible = true, + } = this.props; + + let childrenArr = []; + if (Array.isArray(children)) { + childrenArr = children; + } else { + childrenArr.push(children); + } + // Limit options pane width to 90% of screen. - const styles = getStyles(config.theme2); + const styles = getStyles(config.theme2, splitVisible); // Need to handle when width is relative. ie a percentage of the viewport const paneSizePx = @@ -59,29 +79,38 @@ export class SplitPaneWrapper extends PureComponent { ? paneSize * (splitOrientation === 'horizontal' ? window.innerHeight : window.innerWidth) : paneSize; + // the react split pane library always wants 2 children. This logic ensures that happens, even if one child is passed in + const childrenFragments = [ + {childrenArr[0]}, + {childrenArr[1] || undefined}, + ]; + return ( this.onDragStarted()} onDragFinished={(size) => this.onDragFinished(size)} + paneStyle={paneStyle} pane2Style={secondaryPaneStyle} > - {this.props.children} + {childrenFragments} ); } } -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, hasSplit: boolean) => { const handleColor = theme.v1.palette.blue95; const paneSpacing = theme.spacing(2); const resizer = css` position: relative; + display: ${hasSplit ? 'block' : 'none'}; &::before { content: ''; diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx index 15f9cafcc91..68c248eddc7 100644 --- a/public/app/features/explore/ExplorePaneContainer.tsx +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -1,4 +1,4 @@ -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import memoizeOne from 'memoize-one'; import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; @@ -36,20 +36,18 @@ const getStyles = (theme: GrafanaTheme2) => { display: flex; flex: 1 1 auto; flex-direction: column; + overflow: scroll; + min-width: 600px; & + & { border-left: 1px dotted ${theme.colors.border.medium}; } `, - exploreSplit: css` - width: 50%; - `, }; }; interface OwnProps extends Themeable2 { exploreId: ExploreId; urlQuery: string; - split: boolean; eventBus: EventBus; } @@ -144,11 +142,10 @@ class ExplorePaneContainerUnconnected extends React.PureComponent { }; render() { - const { theme, split, exploreId, initialized, eventBus } = this.props; + const { theme, exploreId, initialized, eventBus } = this.props; const styles = getStyles(theme); - const exploreClass = cx(styles.explore, split && styles.exploreSplit); return ( -
+
{initialized && }
); diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index b746c6f28f3..d5a1df7687d 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -26,7 +26,7 @@ import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors import { ExploreTimeControls } from './ExploreTimeControls'; import { LiveTailButton } from './LiveTailButton'; import { changeDatasource } from './state/datasource'; -import { splitClose, splitOpen } from './state/main'; +import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main'; import { cancelQueries, runQueries } from './state/query'; import { isSplit } from './state/selectors'; import { syncTimes, changeRefreshInterval } from './state/time'; @@ -133,13 +133,24 @@ class UnConnectedExploreToolbar extends PureComponent { isPaused, hasLiveOption, containerWidth, + largerExploreId, } = this.props; const showSmallTimePicker = splitted || containerWidth < 1210; + const isLargerExploreId = largerExploreId === exploreId; + const showExploreToDashboard = contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) || contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor); + const onClickResize = () => { + if (isLargerExploreId) { + this.props.evenPaneResizeAction(); + } else { + this.props.maximizePaneAction({ exploreId: exploreId }); + } + }; + return [ !splitted ? ( { Split ) : ( - - Close - + + + + Close + + ), showExploreToDashboard && ( @@ -285,7 +308,7 @@ class UnConnectedExploreToolbar extends PureComponent { } const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => { - const { syncedTimes } = state.explore; + const { syncedTimes, largerExploreId } = state.explore; const exploreItem = state.explore[exploreId]!; const { datasourceInstance, datasourceMissing, range, refreshInterval, loading, isLive, isPaused, containerWidth } = exploreItem; @@ -307,6 +330,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => { isPaused, syncedTimes, containerWidth, + largerExploreId, }; }; @@ -320,6 +344,8 @@ const mapDispatchToProps = { syncTimes, onChangeTimeZone: updateTimeZoneForSession, onChangeFiscalYearStartMonth: updateFiscalYearStartMonthForSession, + maximizePaneAction, + evenPaneResizeAction, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 5ae2f7a1ea5..8117a1fb533 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -8,7 +8,7 @@ import { locationService, config } from '@grafana/runtime'; import { changeDatasource } from './spec/helper/interactions'; import { makeLogsQueryResponse, makeMetricsQueryResponse } from './spec/helper/query'; import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup'; -import { splitOpen } from './state/main'; +import * as mainState from './state/main'; import * as queryState from './state/query'; jest.mock('app/core/core', () => { @@ -154,7 +154,7 @@ describe('Wrapper', () => { }); }); - describe('Handles open/close splits in UI and URL', () => { + describe('Handles open/close splits and related events in UI and URL', () => { it('opens the split pane when split button is clicked', async () => { setupExplore(); // Wait for rendering the editor @@ -226,8 +226,8 @@ describe('Wrapper', () => { await userEvent.click(closeButtons[1]); await waitFor(() => { - const logsPanels = screen.queryAllByLabelText(/Close split pane/i); - expect(logsPanels.length).toBe(0); + const postCloseButtons = screen.queryAllByLabelText(/Close split pane/i); + expect(postCloseButtons.length).toBe(0); }); }); @@ -261,12 +261,35 @@ describe('Wrapper', () => { // to work await screen.findByText(`loki Editor input: { label="value"}`); - store.dispatch(splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); + store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); // Editor renders the new query await screen.findByText(`elastic Editor input: error`); await screen.findByText(`loki Editor input: { label="value"}`); }); + + it('handles split size events and sets relevant variables', async () => { + setupExplore(); + const splitButton = await screen.findByText(/split/i); + fireEvent.click(splitButton); + await waitForExplore(undefined, true); + let widenButton = await screen.findAllByLabelText('Widen pane'); + let narrowButton = await screen.queryAllByLabelText('Narrow pane'); + const panes = screen.getAllByRole('main'); + expect(widenButton.length).toBe(2); + expect(narrowButton.length).toBe(0); + expect(Number.parseInt(getComputedStyle(panes[0]).width, 10)).toBe(1000); + expect(Number.parseInt(getComputedStyle(panes[1]).width, 10)).toBe(1000); + const resizer = screen.getByRole('presentation'); + fireEvent.mouseDown(resizer, { buttons: 1 }); + fireEvent.mouseMove(resizer, { clientX: -700, buttons: 1 }); + fireEvent.mouseUp(resizer); + widenButton = await screen.findAllByLabelText('Widen pane'); + narrowButton = await screen.queryAllByLabelText('Narrow pane'); + expect(widenButton.length).toBe(1); + expect(narrowButton.length).toBe(1); + // the autosizer is mocked so there is no actual resize here + }); }); describe('Handles document title changes', () => { @@ -295,7 +318,7 @@ describe('Wrapper', () => { // to work await screen.findByText(`loki Editor input: { label="value"}`); - store.dispatch(splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); + store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana')); }); }); diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index ccaf3907a03..f14b5ab9175 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -1,8 +1,11 @@ import { css } from '@emotion/css'; -import React, { useEffect, useRef } from 'react'; +import { inRange } from 'lodash'; +import React, { useEffect, useRef, useState } from 'react'; +import { useWindowSize } from 'react-use'; import { locationService } from '@grafana/runtime'; import { ErrorBoundaryAlert, usePanelContext } from '@grafana/ui'; +import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useNavModel } from 'app/core/hooks/useNavModel'; @@ -16,7 +19,7 @@ import { useCorrelations } from '../correlations/useCorrelations'; import { ExploreActions } from './ExploreActions'; import { ExplorePaneContainer } from './ExplorePaneContainer'; -import { lastSavedUrl, resetExploreAction, saveCorrelationsAction } from './state/main'; +import { lastSavedUrl, saveCorrelationsAction, resetExploreAction, splitSizeUpdateAction } from './state/main'; const styles = { pageScrollbarWrapper: css` @@ -40,6 +43,10 @@ function Wrapper(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) { const { warning } = useAppNotification(); const panelCtx = usePanelContext(); const eventBus = useRef(panelCtx.eventBus.newScopedBus('explore', { onlyLocal: false })); + const [rightPaneWidthRatio, setRightPaneWidthRatio] = useState(0.5); + const { width: windowWidth } = useWindowSize(); + const minWidth = 200; + const exploreState = useSelector((state) => state.explore); useEffect(() => { //This is needed for breadcrumbs and topnav. @@ -97,30 +104,65 @@ function Wrapper(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) { // eslint-disable-next-line react-hooks/exhaustive-deps -- dispatch is stable, doesn't need to be in the deps array }, []); + const updateSplitSize = (size: number) => { + const evenSplitWidth = windowWidth / 2; + const areBothSimilar = inRange(size, evenSplitWidth - 100, evenSplitWidth + 100); + if (areBothSimilar) { + dispatch(splitSizeUpdateAction({ largerExploreId: undefined })); + } else { + dispatch( + splitSizeUpdateAction({ + largerExploreId: size > evenSplitWidth ? ExploreId.right : ExploreId.left, + }) + ); + } + + setRightPaneWidthRatio(size / windowWidth); + }; + const hasSplit = Boolean(queryParams.left) && Boolean(queryParams.right); + let widthCalc = 0; + if (hasSplit) { + if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) { + widthCalc = exploreState.maxedExploreId === ExploreId.right ? windowWidth - minWidth : minWidth; + } else if (exploreState.evenSplitPanes) { + widthCalc = Math.floor(windowWidth / 2); + } else if (rightPaneWidthRatio !== undefined) { + widthCalc = windowWidth * rightPaneWidthRatio; + } + } return (
- - - - {hasSplit && ( + { + if (size) { + updateSplitSize(size); + } + }} + > - + - )} + {hasSplit && ( + + + + )} +
); diff --git a/public/app/features/explore/state/main.test.ts b/public/app/features/explore/state/main.test.ts index 3a5d5839784..1802d78c06f 100644 --- a/public/app/features/explore/state/main.test.ts +++ b/public/app/features/explore/state/main.test.ts @@ -139,7 +139,10 @@ describe('Explore reducer', () => { .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.left })) .thenStateShouldEqual({ + evenSplitPanes: true, + largerExploreId: undefined, left: rightItemMock, + maxedExploreId: undefined, right: undefined, } as unknown as ExploreState); }); @@ -162,7 +165,10 @@ describe('Explore reducer', () => { .givenReducer(exploreReducer, initialState) .whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right })) .thenStateShouldEqual({ + evenSplitPanes: true, + largerExploreId: undefined, left: leftItemMock, + maxedExploreId: undefined, right: undefined, } as unknown as ExploreState); }); diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 3989cdbf359..1dde5fd5a7c 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -40,6 +40,16 @@ export const richHistorySearchFiltersUpdatedAction = createAction<{ export const saveCorrelationsAction = createAction('explore/saveCorrelationsAction'); +export const splitSizeUpdateAction = createAction<{ + largerExploreId?: ExploreId; +}>('explore/splitSizeUpdateAction'); + +export const maximizePaneAction = createAction<{ + exploreId?: ExploreId; +}>('explore/maximizePaneAction'); + +export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction'); + /** * Resets state for explore. */ @@ -163,6 +173,9 @@ export const initialExploreState: ExploreState = { richHistoryStorageFull: false, richHistoryLimitExceededWarningShown: false, richHistoryMigrationFailed: false, + largerExploreId: undefined, + maxedExploreId: undefined, + evenSplitPanes: true, }; /** @@ -179,6 +192,38 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): return { ...state, ...targetSplit, + largerExploreId: undefined, + maxedExploreId: undefined, + evenSplitPanes: true, + }; + } + + if (splitSizeUpdateAction.match(action)) { + const { largerExploreId } = action.payload; + return { + ...state, + largerExploreId, + maxedExploreId: undefined, + evenSplitPanes: largerExploreId === undefined, + }; + } + + if (maximizePaneAction.match(action)) { + const { exploreId } = action.payload; + return { + ...state, + largerExploreId: exploreId, + maxedExploreId: exploreId, + evenSplitPanes: false, + }; + } + + if (evenPaneResizeAction.match(action)) { + return { + ...state, + largerExploreId: undefined, + maxedExploreId: undefined, + evenSplitPanes: true, }; } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 439b39dac2c..7891519890a 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -69,6 +69,21 @@ export interface ExploreState { * True if a warning message about failed rich history has been shown already in this session. */ richHistoryMigrationFailed: boolean; + + /** + * On a split manual resize, we calculate which pane is larger, or if they are roughly the same size. If undefined, it is not split or they are roughly the same size + */ + largerExploreId?: ExploreId; + + /** + * If a maximize pane button is pressed, this indicates which side was maximized. Will be undefined if not split or if it is manually resized + */ + maxedExploreId?: ExploreId; + + /** + * If a minimize pane button is pressed, it will do an even split of panes. Will be undefined if split or on a manual resize + */ + evenSplitPanes?: boolean; } export const EXPLORE_GRAPH_STYLES = ['lines', 'bars', 'points', 'stacked_lines', 'stacked_bars'] as const; From 18738cfd77704799735a8f25aa2c3d20feb5beca Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Thu, 17 Nov 2022 18:08:25 +0100 Subject: [PATCH 292/926] Quota: Fix failure in store due to missing scope parameters (#58874) Quota: Fix failure in store --- pkg/services/quota/quotaimpl/store.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/services/quota/quotaimpl/store.go b/pkg/services/quota/quotaimpl/store.go index d6111580f28..78bc38fe4d9 100644 --- a/pkg/services/quota/quotaimpl/store.go +++ b/pkg/services/quota/quotaimpl/store.go @@ -30,6 +30,10 @@ func (ss *sqlStore) DeleteByUser(ctx quota.Context, userID int64) error { func (ss *sqlStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { limits := quota.Map{} + if scopeParams == nil { + return &limits, nil + } + if scopeParams.OrgID != 0 { orgLimits, err := ss.getOrgScopeQuota(ctx, scopeParams.OrgID) if err != nil { From ea27eca14712639a998235a095d07cf09ca9ff75 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 17 Nov 2022 21:41:46 +0100 Subject: [PATCH 293/926] Email: Use MJML email templates (#57751) Co-authored-by: Santiago --- devenv/README.md | 16 + emails/Makefile | 14 + emails/assets/css/ink.css | 688 ------- emails/assets/css/style.css | 196 -- emails/grunt/aliases.yaml | 11 +- emails/grunt/assemble.js | 11 +- emails/grunt/copy.js | 11 + emails/grunt/premailer.js | 31 - emails/grunt/processhtml.js | 12 - emails/grunt/replace.js | 2 +- emails/grunt/uncss.js | 9 - emails/grunt/watch.js | 16 - emails/package.json | 27 +- emails/templates/alert_notification.html | 134 -- emails/templates/alert_notification.txt | 26 - emails/templates/invited_to_org.html | 47 - emails/templates/invited_to_org.mjml | 40 + emails/templates/invited_to_org.txt | 2 +- emails/templates/layouts/default.html | 161 -- emails/templates/layouts/default.txt | 3 - emails/templates/new_user_invite.html | 49 - emails/templates/new_user_invite.mjml | 36 + emails/templates/ng_alert_notification.html | 271 --- emails/templates/ng_alert_notification.mjml | 122 ++ .../partials/alerting/firing_instance.mjml | 23 + .../partials/alerting/grouping_labels.mjml | 30 + .../partials/alerting/instance_details.mjml | 195 ++ .../partials/alerting/resolved_instance.mjml | 23 + .../templates/partials/alerting/summary.mjml | 28 + emails/templates/partials/layout/default.txt | 3 + emails/templates/partials/layout/footer.mjml | 5 + emails/templates/partials/layout/head.mjml | 10 + emails/templates/partials/layout/header.mjml | 3 + emails/templates/reset_password.html | 42 - emails/templates/reset_password.mjml | 36 + emails/templates/signup_started.html | 46 - emails/templates/signup_started.mjml | 39 + emails/templates/welcome_on_signup.html | 48 - emails/templates/welcome_on_signup.mjml | 40 + go.mod | 10 +- go.sum | 11 + .../ngalert/notifier/channels/email_test.go | 10 +- pkg/services/notifications/notifications.go | 2 + public/emails/alert_notification.txt | 3 +- public/emails/invited_to_org.html | 479 +++-- public/emails/invited_to_org.txt | 6 +- public/emails/new_user_invite.html | 475 ++--- public/emails/new_user_invite.txt | 6 +- public/emails/ng_alert_notification.html | 1663 +++++++++++++---- public/emails/ng_alert_notification.txt | 6 +- public/emails/reset_password.html | 468 ++--- public/emails/reset_password.txt | 6 +- public/emails/signup_started.html | 487 +++-- public/emails/signup_started.txt | 3 +- public/emails/welcome_on_signup.html | 479 +++-- public/emails/welcome_on_signup.txt | 6 +- 56 files changed, 3073 insertions(+), 3553 deletions(-) create mode 100644 emails/Makefile delete mode 100644 emails/assets/css/ink.css delete mode 100644 emails/assets/css/style.css create mode 100644 emails/grunt/copy.js delete mode 100644 emails/grunt/premailer.js delete mode 100644 emails/grunt/processhtml.js delete mode 100644 emails/grunt/uncss.js delete mode 100644 emails/grunt/watch.js delete mode 100644 emails/templates/alert_notification.html delete mode 100644 emails/templates/alert_notification.txt delete mode 100644 emails/templates/invited_to_org.html create mode 100644 emails/templates/invited_to_org.mjml delete mode 100644 emails/templates/layouts/default.html delete mode 100644 emails/templates/layouts/default.txt delete mode 100644 emails/templates/new_user_invite.html create mode 100644 emails/templates/new_user_invite.mjml delete mode 100644 emails/templates/ng_alert_notification.html create mode 100644 emails/templates/ng_alert_notification.mjml create mode 100644 emails/templates/partials/alerting/firing_instance.mjml create mode 100644 emails/templates/partials/alerting/grouping_labels.mjml create mode 100644 emails/templates/partials/alerting/instance_details.mjml create mode 100644 emails/templates/partials/alerting/resolved_instance.mjml create mode 100644 emails/templates/partials/alerting/summary.mjml create mode 100644 emails/templates/partials/layout/default.txt create mode 100644 emails/templates/partials/layout/footer.mjml create mode 100644 emails/templates/partials/layout/head.mjml create mode 100644 emails/templates/partials/layout/header.mjml delete mode 100644 emails/templates/reset_password.html create mode 100644 emails/templates/reset_password.mjml delete mode 100644 emails/templates/signup_started.html create mode 100644 emails/templates/signup_started.mjml delete mode 100644 emails/templates/welcome_on_signup.html create mode 100644 emails/templates/welcome_on_signup.mjml diff --git a/devenv/README.md b/devenv/README.md index 6d6a7065b87..b8cbc39c8e9 100644 --- a/devenv/README.md +++ b/devenv/README.md @@ -4,6 +4,7 @@ This folder contains useful scripts and configuration so you can: - Configure data sources in Grafana for development. - Configure dashboards for development and test scenarios. +- Set up an SMTP Server + Web Interface for viewing and testing emails. - Create docker-compose file with databases and fake data. ## Install Docker @@ -58,6 +59,21 @@ Jaeger block runs both Jaeger and Loki container. Loki container sends traces to | 1.0 | graphite1 | 8280 | 2203 | 2203 | | 0.9 | graphite09 | 8380 | 2303 | 2303 | +#### MailDev + +MailDev block runs an SMTP server and a web UI to test and view emails. This is useful for testing your email notifications locally. + +Make sure you configure your .ini file with the following settings: + +```ini +[smtp] +enabled = true +skip_verify = true +host = "localhost:1025" +``` + +You can access the web UI at http://localhost:12080/#/ + ## Debugging setup in VS Code An example of launch.json is provided in `devenv/vscode/launch.json`. It basically does what Makefile and .bra.toml do. The 'program' field is set to the folder name so VS Code loads all *.go files in it instead of just main.go. diff --git a/emails/Makefile b/emails/Makefile new file mode 100644 index 00000000000..e44bd04b9a1 --- /dev/null +++ b/emails/Makefile @@ -0,0 +1,14 @@ +build: build-html build-txt + +build-html: + npx mjml \ + --config.beautify true \ + --config.minify false \ + --config.validationLevel=strict \ + --config.keepComments=false \ + ./templates/*.mjml --output ../public/emails/ + +build-txt: + npx grunt + +.PHONY: build build-html build-txt diff --git a/emails/assets/css/ink.css b/emails/assets/css/ink.css deleted file mode 100644 index f4c1a291cb3..00000000000 --- a/emails/assets/css/ink.css +++ /dev/null @@ -1,688 +0,0 @@ -/********************************************** -* Ink v1.0.5 - Copyright 2013 ZURB Inc * -**********************************************/ - -/* Client-specific Styles & Reset */ - -#outlook a { - padding:0; -} - -body{ - width:100% !important; - min-width: 100%; - -webkit-text-size-adjust:100%; - -ms-text-size-adjust:100%; - margin:0; - padding:0; -} - - - -.ExternalClass { - width:100%; -} - -.ExternalClass, -.ExternalClass p, -.ExternalClass span, -.ExternalClass font, -.ExternalClass td, -.ExternalClass div { - line-height: 100%; -} - -#backgroundTable { - margin:0; - padding:0; - width:100% !important; - line-height: 100% !important; -} - -img { - outline:none; - text-decoration:none; - -ms-interpolation-mode: bicubic; - width: auto; - float: left; - clear: both; - display: block; -} - -center { - width: 100%; - min-width: 580px; -} - -a img { - border: none; -} - -p { - margin: 0 0 0 10px; -} - -table { - border-spacing: 0; - border-collapse: collapse; -} - -td { - word-break: break-word; - -webkit-hyphens: auto; - -moz-hyphens: auto; - hyphens: auto; - border-collapse: collapse !important; -} - -table, tr, td { - padding: 0; - vertical-align: top; - text-align: left; -} - -hr { - color: #d9d9d9; - background-color: #d9d9d9; - height: 1px; - border: none; -} - -/* Responsive Grid */ - -table.body { - height: 100%; - width: 100%; -} - -table.container { - width: 580px; - margin: 0 auto; - text-align: inherit; -} - -table.row { - padding: 0px; - width: 100%; - position: relative; -} - -table.container table.row { - display: block; -} - -td.wrapper { - padding: 10px 20px 0px 0px; - position: relative; -} - -table.columns, -table.column { - margin: 0 auto; -} - -table.columns td, -table.column td { - padding: 0px 0px 10px; -} - -table.columns td.sub-columns, -table.column td.sub-columns, -table.columns td.sub-column, -table.column td.sub-column { - padding-right: 10px; -} - -td.sub-column, td.sub-columns { - min-width: 0px; -} - -table.row td.last, -table.container td.last { - padding-right: 0px; -} - -table.one { width: 30px; } -table.two { width: 80px; } -table.three { width: 130px; } -table.four { width: 180px; } -table.five { width: 230px; } -table.six { width: 280px; } -table.seven { width: 330px; } -table.eight { width: 380px; } -table.nine { width: 430px; } -table.ten { width: 480px; } -table.eleven { width: 530px; } -table.twelve { width: 580px; } - -table.one center { min-width: 30px; } -table.two center { min-width: 80px; } -table.three center { min-width: 130px; } -table.four center { min-width: 180px; } -table.five center { min-width: 230px; } -table.six center { min-width: 280px; } -table.seven center { min-width: 330px; } -table.eight center { min-width: 380px; } -table.nine center { min-width: 430px; } -table.ten center { min-width: 480px; } -table.eleven center { min-width: 530px; } -table.twelve center { min-width: 580px; } - -table.one .panel center { min-width: 10px; } -table.two .panel center { min-width: 60px; } -table.three .panel center { min-width: 110px; } -table.four .panel center { min-width: 160px; } -table.five .panel center { min-width: 210px; } -table.six .panel center { min-width: 260px; } -table.seven .panel center { min-width: 310px; } -table.eight .panel center { min-width: 360px; } -table.nine .panel center { min-width: 410px; } -table.ten .panel center { min-width: 460px; } -table.eleven .panel center { min-width: 510px; } -table.twelve .panel center { min-width: 560px; } - -.body .columns td.one, -.body .column td.one { width: 8.333333%; } -.body .columns td.two, -.body .column td.two { width: 16.666666%; } -.body .columns td.three, -.body .column td.three { width: 25%; } -.body .columns td.four, -.body .column td.four { width: 33.333333%; } -.body .columns td.five, -.body .column td.five { width: 41.666666%; } -.body .columns td.six, -.body .column td.six { width: 50%; } -.body .columns td.seven, -.body .column td.seven { width: 58.333333%; } -.body .columns td.eight, -.body .column td.eight { width: 66.666666%; } -.body .columns td.nine, -.body .column td.nine { width: 75%; } -.body .columns td.ten, -.body .column td.ten { width: 83.333333%; } -.body .columns td.eleven, -.body .column td.eleven { width: 91.666666%; } -.body .columns td.twelve, -.body .column td.twelve { width: 100%; } - -td.offset-by-one { padding-left: 50px; } -td.offset-by-two { padding-left: 100px; } -td.offset-by-three { padding-left: 150px; } -td.offset-by-four { padding-left: 200px; } -td.offset-by-five { padding-left: 250px; } -td.offset-by-six { padding-left: 300px; } -td.offset-by-seven { padding-left: 350px; } -td.offset-by-eight { padding-left: 400px; } -td.offset-by-nine { padding-left: 450px; } -td.offset-by-ten { padding-left: 500px; } -td.offset-by-eleven { padding-left: 550px; } - -td.expander { - visibility: hidden; - width: 0px; - padding: 0 !important; -} - -table.columns .text-pad, -table.column .text-pad { - padding-left: 10px; - padding-right: 10px; -} - -table.columns .left-text-pad, -table.columns .text-pad-left, -table.column .left-text-pad, -table.column .text-pad-left { - padding-left: 10px; -} - -table.columns .right-text-pad, -table.columns .text-pad-right, -table.column .right-text-pad, -table.column .text-pad-right { - padding-right: 10px; -} - -/* Block Grid */ - -.block-grid { - width: 100%; - max-width: 580px; -} - -.block-grid td { - display: inline-block; - padding:10px; -} - -.two-up td { - width:270px; -} - -.three-up td { - width:173px; -} - -.four-up td { - width:125px; -} - -.five-up td { - width:96px; -} - -.six-up td { - width:76px; -} - -.seven-up td { - width:62px; -} - -.eight-up td { - width:52px; -} - -/* Alignment & Visibility Classes */ - -table.center, td.center { - text-align: center; -} - -h1.center, -h2.center, -h3.center, -h4.center, -h5.center, -h6.center { - text-align: center; -} - -span.center { - display: block; - width: 100%; - text-align: center; -} - -img.center { - margin: 0 auto; - float: none; -} - -.show-for-small, -.hide-for-desktop { - display: none; -} - -/* Typography */ - -body, table.body, h1, h2, h3, h4, h5, h6, p, td { - color: #222222; - font-family: "Helvetica", "Arial", sans-serif; - font-weight: normal; - padding:0; - margin: 0; - text-align: left; - line-height: 1.3; -} - -h1, h2, h3, h4, h5, h6 { - word-break: normal; -} - -h1 {font-size: 40px;} -h2 {font-size: 36px;} -h3 {font-size: 32px;} -h4 {font-size: 28px;} -h5 {font-size: 24px;} -h6 {font-size: 20px;} -body, table.body, p, td {font-size: 14px;line-height:19px;} - -p.lead, p.lede, p.leed { - font-size: 18px; - line-height:21px; -} - -p { - margin-bottom: 10px; -} - -small { - font-size: 10px; -} - -a { - color: #2ba6cb; - text-decoration: none; -} - -a:hover { - color: #2795b6 !important; -} - -a:active { - color: #2795b6 !important; -} - -a:visited { - color: #2ba6cb !important; -} - -h1 a, -h2 a, -h3 a, -h4 a, -h5 a, -h6 a { - color: #2ba6cb; -} - -h1 a:active, -h2 a:active, -h3 a:active, -h4 a:active, -h5 a:active, -h6 a:active { - color: #2ba6cb !important; -} - -h1 a:visited, -h2 a:visited, -h3 a:visited, -h4 a:visited, -h5 a:visited, -h6 a:visited { - color: #2ba6cb !important; -} - -/* Panels */ - -.panel { - background: #f2f2f2; - border: 1px solid #d9d9d9; - padding: 10px !important; -} - -.sub-grid table { - width: 100%; -} - -.sub-grid td.sub-columns { - padding-bottom: 0; -} - -/* Buttons */ - -table.button, -table.tiny-button, -table.small-button, -table.medium-button, -table.large-button { - width: 100%; - overflow: hidden; -} - -table.button td, -table.tiny-button td, -table.small-button td, -table.medium-button td, -table.large-button td { - display: block; - width: auto !important; - text-align: center; - background: #2ba6cb; - border: 1px solid #2284a1; - color: #ffffff; - padding: 8px 0; -} - -table.tiny-button td { - padding: 5px 0 4px; -} - -table.small-button td { - padding: 8px 0 7px; -} - -table.medium-button td { - padding: 12px 0 10px; -} - -table.large-button td { - padding: 21px 0 18px; -} - -table.button td a, -table.tiny-button td a, -table.small-button td a, -table.medium-button td a, -table.large-button td a { - font-weight: bold; - text-decoration: none; - font-family: Helvetica, Arial, sans-serif; - color: #ffffff; - font-size: 16px; -} - -table.tiny-button td a { - font-size: 12px; - font-weight: normal; -} - -table.small-button td a { - font-size: 16px; -} - -table.medium-button td a { - font-size: 20px; -} - -table.large-button td a { - font-size: 24px; -} - -table.button:hover td, -table.button:visited td, -table.button:active td { - background: #2795b6 !important; -} - -table.button:hover td a, -table.button:visited td a, -table.button:active td a { - color: #fff !important; -} - -table.button:hover td, -table.tiny-button:hover td, -table.small-button:hover td, -table.medium-button:hover td, -table.large-button:hover td { - background: #2795b6 !important; -} - -table.button:hover td a, -table.button:active td a, -table.button td a:visited, -table.tiny-button:hover td a, -table.tiny-button:active td a, -table.tiny-button td a:visited, -table.small-button:hover td a, -table.small-button:active td a, -table.small-button td a:visited, -table.medium-button:hover td a, -table.medium-button:active td a, -table.medium-button td a:visited, -table.large-button:hover td a, -table.large-button:active td a, -table.large-button td a:visited { - color: #ffffff !important; -} - -table.secondary td { - background: #e9e9e9; - border-color: #d0d0d0; - color: #555; -} - -table.secondary td a { - color: #555; -} - -table.secondary:hover td { - background: #d0d0d0 !important; - color: #555; -} - -table.secondary:hover td a, -table.secondary td a:visited, -table.secondary:active td a { - color: #555 !important; -} - -table.success td { - background: #5da423; - border-color: #457a1a; -} - -table.success:hover td { - background: #457a1a !important; -} - -table.alert td { - background: #c60f13; - border-color: #970b0e; -} - -table.alert:hover td { - background: #970b0e !important; -} - -table.radius td { - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -table.round td { - -webkit-border-radius: 500px; - -moz-border-radius: 500px; - border-radius: 500px; -} - -/* Outlook First */ - -body.outlook p { - display: inline !important; -} - -/* Media Queries */ - -@media only screen and (max-width: 600px) { - - table[class="body"] img { - - } - - table[class="body"] center { - min-width: 0 !important; - } - - table[class="body"] .container { - width: 95% !important; - } - - table[class="body"] .row { - width: 100% !important; - display: block !important; - } - - table[class="body"] .wrapper { - display: block !important; - padding-right: 0 !important; - } - - table[class="body"] .columns, - table[class="body"] .column { - table-layout: fixed !important; - float: none !important; - width: 100% !important; - padding-right: 0px !important; - padding-left: 0px !important; - display: block !important; - } - - table[class="body"] .wrapper.first .columns, - table[class="body"] .wrapper.first .column { - display: table !important; - } - - table[class="body"] table.columns td, - table[class="body"] table.column td { - width: 100% !important; - } - - table[class="body"] .columns td.one, - table[class="body"] .column td.one { width: 8.333333% !important; } - table[class="body"] .columns td.two, - table[class="body"] .column td.two { width: 16.666666% !important; } - table[class="body"] .columns td.three, - table[class="body"] .column td.three { width: 25% !important; } - table[class="body"] .columns td.four, - table[class="body"] .column td.four { width: 33.333333% !important; } - table[class="body"] .columns td.five, - table[class="body"] .column td.five { width: 41.666666% !important; } - table[class="body"] .columns td.six, - table[class="body"] .column td.six { width: 50% !important; } - table[class="body"] .columns td.seven, - table[class="body"] .column td.seven { width: 58.333333% !important; } - table[class="body"] .columns td.eight, - table[class="body"] .column td.eight { width: 66.666666% !important; } - table[class="body"] .columns td.nine, - table[class="body"] .column td.nine { width: 75% !important; } - table[class="body"] .columns td.ten, - table[class="body"] .column td.ten { width: 83.333333% !important; } - table[class="body"] .columns td.eleven, - table[class="body"] .column td.eleven { width: 91.666666% !important; } - table[class="body"] .columns td.twelve, - table[class="body"] .column td.twelve { width: 100% !important; } - - table[class="body"] td.offset-by-one, - table[class="body"] td.offset-by-two, - table[class="body"] td.offset-by-three, - table[class="body"] td.offset-by-four, - table[class="body"] td.offset-by-five, - table[class="body"] td.offset-by-six, - table[class="body"] td.offset-by-seven, - table[class="body"] td.offset-by-eight, - table[class="body"] td.offset-by-nine, - table[class="body"] td.offset-by-ten, - table[class="body"] td.offset-by-eleven { - padding-left: 0 !important; - } - - table[class="body"] table.columns td.expander { - width: 1px !important; - } - - table[class="body"] .right-text-pad, - table[class="body"] .text-pad-right { - padding-left: 10px !important; - } - - table[class="body"] .left-text-pad, - table[class="body"] .text-pad-left { - padding-right: 10px !important; - } - - table[class="body"] .hide-for-small, - table[class="body"] .show-for-desktop { - display: none !important; - } - - table[class="body"] .show-for-small, - table[class="body"] .hide-for-desktop { - display: inherit !important; - } -} diff --git a/emails/assets/css/style.css b/emails/assets/css/style.css deleted file mode 100644 index 065421a173d..00000000000 --- a/emails/assets/css/style.css +++ /dev/null @@ -1,196 +0,0 @@ - -body, table.body, h1, h2, h3, h4, h5, h6, p, td { - font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -webkit-text-size-adjust: none; -} - -h1 {font-size: 40px;} -h2 {font-size: 36px;} -h3 { - font-size: 22px; - margin-top: 10px; - margin-bottom: 10px; -} -h4 {font-size: 20px;} -h5 {font-size: 18px;} -h6 {font-size: 16px;} - -.emphasis { - font-weight: 600; -} - -a { - color: #E67612; - text-decoration: none; -} - -a:hover { - color: #ff8f2b !important; -} - -a:active { - color: #F2821E !important; -} - -a:visited { - color: #E67612 !important; -} - -table.facebook td { - background: #3b5998; - border-color: #2d4473; -} - -table.facebook:hover td { - background: #2d4473 !important; -} - -table.twitter td { - background: #00acee; - border-color: #0087bb; -} - -table.twitter:hover td { - background: #0087bb !important; -} - -table.google-plus td { - background-color: #DB4A39; - border-color: #CC0000; -} - -table.google-plus:hover td { - background: #CC0000 !important; -} - -.template-label { - color: #ffffff; - font-weight: bold; - font-size: 11px; -} - -.callout .wrapper { - padding-bottom: 20px; -} - -.callout .panel { - background: #ECF8FF; - border-color: #b9e5ff; -} - -.header { -margin-top:25px; -margin-bottom: 25px; -} - -.data { - font-size: 16px; -} - -.footer { - background-color: #2e2e2e; - color: #999999; - margin: 0 auto; - width: 100%; -} - -@media only screen and (max-width: 600px) { - table[class="body"] .right-text-pad { - padding-left: 10px !important; - } - - table[class="body"] .left-text-pad { - padding-right: 10px !important; - } - - .logo { - margin-left: 10px; - } -} - -table.better-button { - margin-top: 10px; - margin-bottom: 20px; -} - -table.columns td.better-button { - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - padding-bottom: 0px; -} - -.better-button a { - text-decoration: none; - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - - padding: 12px 25px; - border: 1px solid #ff8f2b; - display: inline-block; - color: #FFF; -} - -.better-button:hover a { - color: #FFFFFF !important; - background-color: #F2821E; - border: 1px solid #F2821E; -} - -.better-button:visited a { - color: #FFFFFF !important; -} - -.better-button:active a { - color: #FFFFFF !important; -} - -table.better-button-alt { - margin-top: 10px; - margin-bottom: 20px; -} - -table.columns td.better-button-alt { - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - padding-bottom: 0px; -} - -.better-button-alt a { - text-decoration: none; - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - - padding: 12px 25px; - border: 1px solid #ff8f2b; - background-color: #EFEFEF; - display: inline-block; - color: #ff8f2b; -} - -.better-button-alt:hover a { - color: #ff8f2b !important; - background-color: #DDDDDD; - border: 1px solid #F2821E; -} - -.better-button-alt:visited a { - color: #ff8f2b !important; -} - -.better-button-alt:active a { - color: #ff8f2b !important; -} - -.verification-code { - background-color: #EEEEEE; - padding: 3px; - margin: 8px; - display: inline-block; - font-weight: bold; - font-size: 20px; -} diff --git a/emails/grunt/aliases.yaml b/emails/grunt/aliases.yaml index 6a2e47777cd..550d7a5422a 100644 --- a/emails/grunt/aliases.yaml +++ b/emails/grunt/aliases.yaml @@ -1,8 +1,5 @@ - default: - - 'clean' - - 'assemble' - - 'replace' - - 'uncss' - - 'processhtml' - - 'premailer' + - 'clean' + - 'assemble' + - 'replace' + - 'copy' diff --git a/emails/grunt/assemble.js b/emails/grunt/assemble.js index 6ef46860267..e0d1f103a42 100644 --- a/emails/grunt/assemble.js +++ b/emails/grunt/assemble.js @@ -2,21 +2,12 @@ module.exports = function () { 'use strict'; return { options: { - partials: ['templates/partials/*.hbs'], - helpers: ['templates/helpers/**/*.js'], data: [], flatten: true, }, - html: { - options: { - layout: 'templates/layouts/default.html', - }, - src: ['templates/*.html'], - dest: 'dist/', - }, txt: { options: { - layout: 'templates/layouts/default.txt', + layout: 'templates/partials/layout/default.txt', ext: '.txt', }, src: ['templates/*.txt'], diff --git a/emails/grunt/copy.js b/emails/grunt/copy.js new file mode 100644 index 00000000000..4f794d85a3a --- /dev/null +++ b/emails/grunt/copy.js @@ -0,0 +1,11 @@ +module.exports = function () { + 'use strict'; + return { + txt: { + expand: true, + cwd: 'dist', + src: ['**.txt'], + dest: '../public/emails/', + }, + }; +}; diff --git a/emails/grunt/premailer.js b/emails/grunt/premailer.js deleted file mode 100644 index f587095c256..00000000000 --- a/emails/grunt/premailer.js +++ /dev/null @@ -1,31 +0,0 @@ -module.exports = { - html: { - options: { - verbose: true, - removeComments: true, - }, - files: [ - { - expand: true, // Enable dynamic expansion. - cwd: 'dist', // Src matches are relative to this path. - src: ['*.html'], // Actual pattern(s) to match. - dest: '../public/emails/', // Destination path prefix. - }, - ], - }, - txt: { - options: { - verbose: true, - mode: 'txt', - lineLength: 90, - }, - files: [ - { - expand: true, // Enable dynamic expansion. - cwd: 'dist', // Src matches are relative to this path. - src: ['*.txt'], // Actual patterns to match. - dest: '../public/emails/', // Destination path prefix. - }, - ], - }, -}; diff --git a/emails/grunt/processhtml.js b/emails/grunt/processhtml.js deleted file mode 100644 index 777b2d27d73..00000000000 --- a/emails/grunt/processhtml.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - dist: { - files: [ - { - expand: true, // Enable dynamic expansion. - cwd: 'dist', // Src matches are relative to this path. - src: ['*.html'], // Actual pattern(s) to match. - dest: 'dist/', // Destination path prefix. - }, - ], - }, -}; diff --git a/emails/grunt/replace.js b/emails/grunt/replace.js index 0d8c030d2f3..be4f18e34d4 100644 --- a/emails/grunt/replace.js +++ b/emails/grunt/replace.js @@ -1,7 +1,7 @@ module.exports = { dist: { overwrite: true, - src: ['dist/*.html', 'dist/*.txt'], + src: ['dist/*.txt'], replacements: [ { from: '[[', diff --git a/emails/grunt/uncss.js b/emails/grunt/uncss.js deleted file mode 100644 index c1ec535e1bb..00000000000 --- a/emails/grunt/uncss.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - dist: { - src: ['dist/*.html'], - dest: 'dist/css/tidy.css', - options: { - report: 'min', // optional: include to report savings - }, - }, -}; diff --git a/emails/grunt/watch.js b/emails/grunt/watch.js deleted file mode 100644 index b071320b3e6..00000000000 --- a/emails/grunt/watch.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - src: { - files: [ - //what are the files that we want to watch - 'assets/css/*.css', - 'templates/**/*.html', - 'templates/**/*.txt', - 'grunt/*.js', - ], - tasks: ['default'], - options: { - nospawn: true, - livereload: false, - }, - }, -}; diff --git a/emails/package.json b/emails/package.json index f4eb62d4eae..c8de033b507 100644 --- a/emails/package.json +++ b/emails/package.json @@ -1,26 +1,17 @@ { - "name": "Grafana-Email-Campaign", + "name": "grafana-email-campaign", "version": "1.0.0", - "description": "Grafana Email templates based on Zurb Ink", - "repository": "dnnsldr/", - "author": { - "name": "dnnsldr", - "email": "delder@riester.com", - "url": "https://github.com/dnnsldr" - }, - "scripts": { - "build": "grunt", - "start": "grunt watch" - }, + "description": "Grafana Email templates based on MJML", + "author": "Grafana Labs", "devDependencies": { "grunt": "1.0.1", - "grunt-premailer": "1.1.0", - "grunt-processhtml": "^0.4.2", - "grunt-uncss": "0.9.0", - "load-grunt-config": "3.0.1", + "grunt-assemble": "0.6.3", + "grunt-cli": "^1.4.3", + "grunt-contrib-clean": "2.0.0", + "grunt-contrib-copy": "^1.0.0", "grunt-contrib-watch": "1.1.0", "grunt-text-replace": "0.4.0", - "grunt-assemble": "0.6.3", - "grunt-contrib-clean": "2.0.0" + "load-grunt-config": "3.0.1", + "mjml": "^4.13.0" } } diff --git a/emails/templates/alert_notification.html b/emails/templates/alert_notification.html deleted file mode 100644 index 98d2f7ff56c..00000000000 --- a/emails/templates/alert_notification.html +++ /dev/null @@ -1,134 +0,0 @@ -[[Subject .Subject "[[.Title]]"]] - -
{formatDate(timeZone, key.created)} {formatLastUsedAtDate(timeZone, key.lastUsedAt)} - {key.isRevoked && Revoked} - {key.isRevoked && } { + const styles = useStyles2(getStyles); + return ( + + Revoked + + + + + + + ); +}; + interface TokenExpirationProps { timeZone: TimeZone; token: ApiKey; From d001a1b035bdbbaae384cdaf3d69f9ebfd5193a0 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Mon, 7 Nov 2022 12:10:30 +0100 Subject: [PATCH 076/926] @grafana/e2e: Fix addPanel for small screen size (#57398) --- packages/grafana-e2e/src/flows/configurePanel.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/grafana-e2e/src/flows/configurePanel.ts b/packages/grafana-e2e/src/flows/configurePanel.ts index d3aeb699629..0c98a89b8c7 100644 --- a/packages/grafana-e2e/src/flows/configurePanel.ts +++ b/packages/grafana-e2e/src/flows/configurePanel.ts @@ -90,7 +90,13 @@ export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelC e2e.components.Panels.Panel.title(panelTitle).click(); e2e.components.Panels.Panel.headerItems('Edit').click(); } else { - e2e.components.PageToolbar.item('Add panel').click(); + try { + e2e.components.PageToolbar.item('Add panel').click(); + } catch (e) { + // Depending on the screen size, the "Add panel" button might be hidden + e2e.components.PageToolbar.item('Show more items').click(); + e2e.components.PageToolbar.item('Add panel').last().click(); + } e2e.pages.AddDashboard.addNewPanel().click(); } From 6dd5ce7ab0892ab25eebf14d5760e6568f50ba52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 7 Nov 2022 12:24:01 +0100 Subject: [PATCH 077/926] Tracing: Allow trace to logs for OpenSearch (#58161) * tracing: allow trace-to-opensearch functionality * more consistent naming --- .../TraceToLogs/TraceToLogsSettings.tsx | 7 ++++- .../explore/TraceView/createSpanLink.test.ts | 30 +++++++++---------- .../explore/TraceView/createSpanLink.tsx | 21 ++++++++++--- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx index 0b7933461f6..b80b7320ed6 100644 --- a/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx +++ b/public/app/core/components/TraceToLogs/TraceToLogsSettings.tsx @@ -34,7 +34,12 @@ interface Props extends DataSourcePluginOptionsEditorProps {} export function TraceToLogsSettings({ options, onOptionsChange }: Props) { const styles = useStyles2(getStyles); - const supportedDataSourceTypes = ['loki', 'grafana-splunk-datasource', 'elasticsearch']; + const supportedDataSourceTypes = [ + 'loki', + 'elasticsearch', + 'grafana-splunk-datasource', // external + 'grafana-opensearch-datasource', // external + ]; return (
diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index f65a1241f1c..daa46073e2f 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -594,14 +594,14 @@ describe('createSpanLinkFactory', () => { }); }); - describe('elasticsearch link', () => { - const elasticsearchUID = 'elasticsearchUID'; + describe('elasticsearch/opensearch link', () => { + const searchUID = 'searchUID'; beforeAll(() => { setDataSourceSrv({ getInstanceSettings() { return { - uid: elasticsearchUID, + uid: searchUID, name: 'Elasticsearch', type: 'elasticsearch', } as unknown as DataSourceInstanceSettings; @@ -614,7 +614,7 @@ describe('createSpanLinkFactory', () => { it('creates link with correct simple query', () => { const createLink = setupSpanLinkFactory({ - datasourceUid: elasticsearchUID, + datasourceUid: searchUID, }); const links = createLink!(createTraceSpan()); @@ -622,14 +622,14 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toContain( encodeURIComponent( - `datasource":"${elasticsearchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` + `datasource":"${searchUID}","queries":[{"query":"cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}]` ) ); }); it('automatically timeshifts the time range by one second in a query', () => { const createLink = setupSpanLinkFactory({ - datasourceUid: elasticsearchUID, + datasourceUid: searchUID, }); const links = createLink!(createTraceSpan()); @@ -646,11 +646,11 @@ describe('createSpanLinkFactory', () => { it('formats query correctly if filterByTraceID and or filterBySpanID is true', () => { const createLink = setupSpanLinkFactory( { - datasourceUid: elasticsearchUID, + datasourceUid: searchUID, filterByTraceID: true, filterBySpanID: true, }, - elasticsearchUID + searchUID ); expect(createLink).toBeDefined(); @@ -660,7 +660,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${elasticsearchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"\\"6605c7b08e715d6c\\" AND \\"7946b05c2e2e4e5a\\" AND cluster:\\"cluster1\\" AND hostname:\\"hostname1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` )}` ); }); @@ -670,7 +670,7 @@ describe('createSpanLinkFactory', () => { { tags: ['ip'], }, - elasticsearchUID + searchUID ); expect(createLink).toBeDefined(); const links = createLink!( @@ -686,7 +686,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${elasticsearchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` )}` ); }); @@ -696,7 +696,7 @@ describe('createSpanLinkFactory', () => { { tags: ['ip', 'hostname'], }, - elasticsearchUID + searchUID ); expect(createLink).toBeDefined(); const links = createLink!( @@ -715,7 +715,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${elasticsearchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"hostname:\\"hostname1\\" AND ip:\\"192.168.0.1\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` )}` ); }); @@ -729,7 +729,7 @@ describe('createSpanLinkFactory', () => { { key: 'k8s.pod.name', value: 'pod' }, ], }, - elasticsearchUID + searchUID ); expect(createLink).toBeDefined(); const links = createLink!( @@ -748,7 +748,7 @@ describe('createSpanLinkFactory', () => { expect(linkDef).toBeDefined(); expect(linkDef!.href).toBe( `/explore?left=${encodeURIComponent( - `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${elasticsearchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` + `{"range":{"from":"2020-10-14T01:00:00.000Z","to":"2020-10-14T01:00:01.000Z"},"datasource":"${searchUID}","queries":[{"query":"service:\\"serviceName\\" AND pod:\\"podName\\"","refId":"","metrics":[{"id":"1","type":"logs"}]}],"panelsState":{}}` )}` ); }); diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 6d06f8c8ec6..a88d77d18ae 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -22,7 +22,6 @@ import { SpanLinkFunc, TraceSpan } from '@jaegertracing/jaeger-ui-components'; import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricQuery, TraceToMetricsOptions } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { ElasticsearchQuery } from 'app/plugins/datasource/elasticsearch/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; import { LokiQuery } from '../../../plugins/datasource/loki/types'; @@ -116,7 +115,11 @@ function legacyCreateSpanLinkFactory( dataLink = getLinkForSplunk(span, traceToLogsOptions, logsDataSourceSettings); break; case 'elasticsearch': - dataLink = getLinkForElasticsearch(span, traceToLogsOptions, logsDataSourceSettings); + dataLink = getLinkForElasticsearchOrOpensearch(span, traceToLogsOptions, logsDataSourceSettings); + break; + case 'grafana-opensearch-datasource': + dataLink = getLinkForElasticsearchOrOpensearch(span, traceToLogsOptions, logsDataSourceSettings); + break; } if (dataLink) { @@ -283,7 +286,17 @@ function getLinkForLoki(span: TraceSpan, options: TraceToLogsOptions, dataSource return dataLink; } -function getLinkForElasticsearch( +// we do not have access to the dataquery type for opensearch, +// so here is a minimal interface that handles both elasticsearch and opensearch. +interface ElasticsearchOrOpensearchQuery extends DataQuery { + query: string; + metrics: Array<{ + id: string; + type: 'logs'; + }>; +} + +function getLinkForElasticsearchOrOpensearch( span: TraceSpan, options: TraceToLogsOptions, dataSourceSettings: DataSourceInstanceSettings @@ -316,7 +329,7 @@ function getLinkForElasticsearch( query = `"${span.spanID}" AND ` + query; } - const dataLink: DataLink = { + const dataLink: DataLink = { title: dataSourceSettings.name, url: '', internal: { From d4e3d47f563de34cd09372e048943a8698861d59 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 7 Nov 2022 11:37:47 +0000 Subject: [PATCH 078/926] Chore: Disable dashboard-time-zone e2e tests (#58320) --- e2e/dashboards-suite/dashboard-time-zone.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/dashboards-suite/dashboard-time-zone.spec.ts b/e2e/dashboards-suite/dashboard-time-zone.spec.ts index 125fbc51306..08686be2cbc 100644 --- a/e2e/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e/dashboards-suite/dashboard-time-zone.spec.ts @@ -16,7 +16,7 @@ e2e.scenario({ itName: 'Tests dashboard time zone scenarios', addScenarioDataSource: false, addScenarioDashBoard: false, - skipScenario: false, + skipScenario: true, scenario: () => { e2e.flows.openDashboard({ uid: '5SdHCasdf' }); From 40ba2ba18d3c5d343476368bbf9dfa1d2399ab28 Mon Sep 17 00:00:00 2001 From: Conor Evans <43791257+conorevans@users.noreply.github.com> Date: Mon, 7 Nov 2022 12:29:27 +0000 Subject: [PATCH 079/926] fix(config/jwt): the value should be "expect_claims", not "expected_claims" (#58284) Signed-off-by: Conor Evans --- conf/defaults.ini | 2 +- conf/sample.ini | 2 +- devenv/docker/blocks/auth/jwt_proxy/readme.md | 2 +- devenv/docker/blocks/auth/oauth/readme.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index cbe9b4d6878..305aa20b949 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -639,7 +639,7 @@ username_claim = jwk_set_url = jwk_set_file = cache_ttl = 60m -expected_claims = {} +expect_claims = {} key_file = role_attribute_path = role_attribute_strict = false diff --git a/conf/sample.ini b/conf/sample.ini index b482b0ea0ea..7a04486abeb 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -629,7 +629,7 @@ ;jwk_set_url = https://foo.bar/.well-known/jwks.json ;jwk_set_file = /path/to/jwks.json ;cache_ttl = 60m -;expected_claims = {"aud": ["foo", "bar"]} +;expect_claims = {"aud": ["foo", "bar"]} ;key_file = /path/to/key/file ;role_attribute_path = ;role_attribute_strict = false diff --git a/devenv/docker/blocks/auth/jwt_proxy/readme.md b/devenv/docker/blocks/auth/jwt_proxy/readme.md index 425be0ca7e8..75819652220 100644 --- a/devenv/docker/blocks/auth/jwt_proxy/readme.md +++ b/devenv/docker/blocks/auth/jwt_proxy/readme.md @@ -20,7 +20,7 @@ username_claim = login email_claim = email jwk_set_file = devenv/docker/blocks/auth/oauth/jwks.json cache_ttl = 60m -expected_claims = {"iss": "http://env.grafana.local:8087/auth/realms/grafana", "azp": "grafana-oauth"} +expect_claims = {"iss": "http://env.grafana.local:8087/auth/realms/grafana", "azp": "grafana-oauth"} auto_sign_up = true role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' role_attribute_strict = false diff --git a/devenv/docker/blocks/auth/oauth/readme.md b/devenv/docker/blocks/auth/oauth/readme.md index c2a8b34ccf6..e0966a8b717 100644 --- a/devenv/docker/blocks/auth/oauth/readme.md +++ b/devenv/docker/blocks/auth/oauth/readme.md @@ -48,7 +48,7 @@ username_claim = login email_claim = email jwk_set_file = devenv/docker/blocks/auth/oauth/jwks.json cache_ttl = 60m -expected_claims = {"iss": "http://localhost:8087/auth/realms/grafana", "azp": "grafana-oauth"} +expect_claims = {"iss": "http://localhost:8087/auth/realms/grafana", "azp": "grafana-oauth"} auto_sign_up = true ``` @@ -96,7 +96,7 @@ username_claim = login email_claim = email jwk_set_url = /auth/realms/grafana/protocol/openid-connect/certs cache_ttl = 60m -expected_claims = {"iss": "http://localhost:8087/auth/realms/grafana", "azp": "grafana-oauth"} +expect_claims = {"iss": "http://localhost:8087/auth/realms/grafana", "azp": "grafana-oauth"} auto_sign_up = true ``` From 97df6e682ea9b6b2204d6ffe080a4a333eef1798 Mon Sep 17 00:00:00 2001 From: Virginia Cepeda Date: Mon, 7 Nov 2022 09:44:20 -0300 Subject: [PATCH 080/926] [Alerting] - make rule groups the default view (#58271) --- .../alerting/unified/components/rules/GrafanaRules.tsx | 6 +++--- .../alerting/unified/components/rules/RulesFilter.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx b/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx index 54d9e176ed9..c8a8e4f0943 100644 --- a/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx +++ b/public/app/features/alerting/unified/components/rules/GrafanaRules.tsx @@ -34,8 +34,8 @@ export const GrafanaRules: FC = ({ namespaces, expandAll }) => { const loading = prom.loading || ruler.loading; const hasResult = !!prom.result || !!ruler.result; - const wantsGroupedView = queryParams['view'] === 'grouped'; - const namespacesFormat = wantsGroupedView ? namespaces : flattenGrafanaManagedRules(namespaces); + const wantsListView = queryParams['view'] === 'list'; + const namespacesFormat = wantsListView ? flattenGrafanaManagedRules(namespaces) : namespaces; const groupsWithNamespaces = useCombinedGroupNamespace(namespacesFormat); @@ -58,7 +58,7 @@ export const GrafanaRules: FC = ({ namespaces, expandAll }) => { key={`${namespace.name}-${group.name}`} namespace={namespace} expandAll={expandAll} - viewMode={wantsGroupedView ? 'grouped' : 'list'} + viewMode={wantsListView ? 'list' : 'grouped'} /> ))} {hasResult && namespacesFormat?.length === 0 &&

No rules found.

} diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx index ba408a51ea8..1ec596341db 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -14,16 +14,16 @@ import { getFiltersFromUrlParams } from '../../utils/misc'; import { alertStateToReadable } from '../../utils/rules'; const ViewOptions: SelectableValue[] = [ - { - icon: 'list-ul', - label: 'List', - value: 'list', - }, { icon: 'folder', label: 'Grouped', value: 'grouped', }, + { + icon: 'list-ul', + label: 'List', + value: 'list', + }, { icon: 'heart-rate', label: 'State', From e5d4d00c1fca7e934eaf39a3fe50b5d4f26211d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Mon, 7 Nov 2022 14:42:20 +0100 Subject: [PATCH 081/926] Internationalization: Translate CalendarFooter component (#58326) --- .../DateTimePickers/TimeRangePicker/CalendarFooter.tsx | 5 +++-- public/locales/de-DE/grafana.json | 2 ++ public/locales/en-US/grafana.json | 2 ++ public/locales/es-ES/grafana.json | 2 ++ public/locales/fr-FR/grafana.json | 2 ++ public/locales/pseudo-LOCALE/grafana.json | 2 ++ public/locales/zh-Hans/grafana.json | 2 ++ 7 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx index 59d54f6020e..b8ba437d1bd 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarFooter.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../themes'; +import { Trans } from '../../../utils/i18n'; import { Button } from '../../Button'; import { TimePickerCalendarProps } from './TimePickerCalendar'; @@ -14,10 +15,10 @@ export function Footer({ onClose, onApply }: TimePickerCalendarProps) { return (
); diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 7904fc13f48..56c51cddda1 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -413,6 +413,8 @@ "title": "Absoluter Zeitbereich" }, "calendar": { + "apply-button": "", + "cancel-button": "", "select-time": "" }, "time-range": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bf33496a5f3..40b777faea6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -413,6 +413,8 @@ "title": "Absolute time range" }, "calendar": { + "apply-button": "Apply time range", + "cancel-button": "Cancel", "select-time": "Select a time range" }, "time-range": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 20a1b8f6426..c4e399bd743 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -413,6 +413,8 @@ "title": "Intervalo de tiempo absoluto" }, "calendar": { + "apply-button": "", + "cancel-button": "", "select-time": "" }, "time-range": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 3768f52b6c8..2d8efa6ff75 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -413,6 +413,8 @@ "title": "Période temporelle absolue" }, "calendar": { + "apply-button": "", + "cancel-button": "", "select-time": "" }, "time-range": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 8432633bbbc..6f66c3caba3 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -413,6 +413,8 @@ "title": "Åþşőľūŧę ŧįmę řäʼnģę" }, "calendar": { + "apply-button": "Åppľy ŧįmę řäʼnģę", + "cancel-button": "Cäʼnčęľ", "select-time": "Ŝęľęčŧ ä ŧįmę řäʼnģę" }, "time-range": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 1df34e9f761..4666c5c50ec 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -413,6 +413,8 @@ "title": "绝对时间范围" }, "calendar": { + "apply-button": "", + "cancel-button": "", "select-time": "" }, "time-range": { From f9c88e72aee271b7072f85581fc4ed71554d11c8 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 7 Nov 2022 09:09:19 -0500 Subject: [PATCH 082/926] Alerting: Update saveAlertStates in state manager to not return results (#58279) --- pkg/services/ngalert/state/manager.go | 32 +++++++++------------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index d46b5414a6a..f3a00b573e9 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -179,10 +179,8 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time processedResults[s.State.CacheID] = s.State } resolvedStates := st.staleResultsHandler(ctx, evaluatedAt, alertRule, processedResults, logger) - if len(states) > 0 && st.instanceStore != nil { - logger.Debug("Saving new states to the database", "count", len(states)) - _, _ = st.saveAlertStates(ctx, states...) - } + + st.saveAlertStates(ctx, logger, states...) changedStates := make([]StateTransition, 0, len(states)) for _, s := range states { @@ -284,29 +282,19 @@ func (st *Manager) Put(states []*State) { } // TODO: Is the `State` type necessary? Should it embed the instance? -func (st *Manager) saveAlertStates(ctx context.Context, states ...StateTransition) (saved, failed int) { - logger := st.log.FromContext(ctx) +func (st *Manager) saveAlertStates(ctx context.Context, logger log.Logger, states ...StateTransition) { if st.instanceStore == nil { - return 0, 0 + return } logger.Debug("Saving alert states", "count", len(states)) instances := make([]ngModels.AlertInstance, 0, len(states)) - type debugInfo struct { - OrgID int64 - Uid string - State string - Labels string - } - debug := make([]debugInfo, 0) - for _, s := range states { labels := ngModels.InstanceLabels(s.Labels) _, hash, err := labels.StringAndHash() if err != nil { - debug = append(debug, debugInfo{s.OrgID, s.AlertRuleUID, s.State.State.String(), s.Labels.String()}) - logger.Error("Failed to save alert instance with invalid labels", "error", err) + logger.Error("Failed to create a key for alert state to save it to database. The state will be ignored ", "cacheID", s.CacheID, "error", err) continue } fields := ngModels.AlertInstance{ @@ -326,14 +314,16 @@ func (st *Manager) saveAlertStates(ctx context.Context, states ...StateTransitio } if err := st.instanceStore.SaveAlertInstances(ctx, instances...); err != nil { + type debugInfo struct { + State string + Labels string + } + debug := make([]debugInfo, 0) for _, inst := range instances { - debug = append(debug, debugInfo{inst.RuleOrgID, inst.RuleUID, string(inst.CurrentState), data.Labels(inst.Labels).String()}) + debug = append(debug, debugInfo{string(inst.CurrentState), data.Labels(inst.Labels).String()}) } logger.Error("Failed to save alert states", "states", debug, "error", err) - return 0, len(debug) } - - return len(instances), len(debug) } // TODO: why wouldn't you allow other types like NoData or Error? From eb3ee35e1ca5ab14aa8de2f7dcdc192da9068437 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 7 Nov 2022 15:19:31 +0100 Subject: [PATCH 083/926] Frontend Routing: Always render standalone plugin pages using the `` (#57771) * chore: fix go lint issues * feat(Routing): route standalone plugin pages to the `AppRoutePage` * feat(plugin.json): introduce a new field called `isCorePage` for `includes` * chore: add explanatory comments for types * refactor(AppRootPage): receive the `pluginId` and `pluginSection` through the props Now we are able to receive these as props as the pluginId is defined on navLinks that are registered by plugins. * chore: update teests for AppRootPage * fix: remove rebase issue * tests(applinks): add a test for checking isCorePage plugin page setting * refactor(applinks): update tests to use FindById() and be more resilient to changes * fix: Go lint issues * refactor(routes): use cleaner types when working with plugin nav nodes Co-authored-by: Marcus Andersson * chore: fix linting issues * t: remove `isCorePage` field from includes Co-authored-by: Marcus Andersson --- .betterer.results | 3 - packages/grafana-data/src/types/plugin.ts | 7 +- pkg/services/navtree/navtreeimpl/applinks.go | 10 +- .../navtree/navtreeimpl/applinks_test.go | 181 ++++++++++++++---- .../plugins/components/AppRootPage.test.tsx | 26 ++- .../plugins/components/AppRootPage.tsx | 48 +++-- public/app/features/plugins/routes.tsx | 34 ++++ public/app/features/plugins/utils.test.ts | 51 +---- public/app/features/plugins/utils.ts | 46 +---- public/app/routes/routes.tsx | 19 +- 10 files changed, 249 insertions(+), 176 deletions(-) create mode 100644 public/app/features/plugins/routes.tsx diff --git a/.betterer.results b/.betterer.results index d6008a4036f..e0890b5e1b6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4431,9 +4431,6 @@ exports[`better eslint`] = { "public/app/features/plugins/components/AppRootPage.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/plugins/components/AppRootPage.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/plugins/datasource_srv.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 0d36d4ce3d9..0d718e92fa7 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -110,8 +110,11 @@ export interface PluginInclude { path?: string; icon?: string; - role?: string; // "Viewer", Admin, editor??? - addToNav?: boolean; // Show in the sidebar... only if type=page? + // "Admin", "Editor" or "Viewer". If set then the include will only show up in the navigation if the user has the required roles. + role?: string; + + // Adds the "page" or "dashboard" type includes to the navigation if set to `true`. + addToNav?: boolean; // Angular app pages component?: string; diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index b67bd8f0208..35d4230133d 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -86,7 +86,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo continue } - if include.Type == "page" && include.AddToNav { + if include.Type == "page" { link := &navtree.NavLink{ Text: include.Name, Icon: include.Icon, @@ -95,7 +95,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo if len(include.Path) > 0 { link.Url = s.cfg.AppSubURL + include.Path - if include.DefaultNav { + if include.DefaultNav && include.AddToNav { appLink.Url = link.Url } } else { @@ -127,7 +127,9 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo sectionForPage.Children = append(sectionForPage.Children, link) } } - } else { + + // Register the page under the app + } else if include.AddToNav { appLink.Children = append(appLink.Children, link) } } @@ -169,7 +171,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo // Handle moving apps into specific navtree sections alertingNode := treeRoot.FindById(navtree.NavIDAlerting) - sectionID := "apps" + sectionID := navtree.NavIDApps if navConfig, hasOverride := s.navigationAppConfig[plugin.ID]; hasOverride { appLink.SortWeight = navConfig.SortWeight diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index 82b791090ad..149805ae096 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -72,12 +72,24 @@ func TestAddAppLinks(t *testing.T) { Type: plugins.App, Includes: []*plugins.Includes{ { - Name: "Hello", - Path: "/connections/connect-data", + Name: "Default page", + Path: "/a/test-app3/default", Type: "page", AddToNav: true, DefaultNav: true, }, + { + Name: "Random page", + Path: "/a/test-app3/random-page", + Type: "page", + AddToNav: true, + }, + { + Name: "Connect data", + Path: "/connections/connect-data", + Type: "page", + AddToNav: false, + }, }, }, } @@ -113,20 +125,27 @@ func TestAddAppLinks(t *testing.T) { treeRoot := navtree.NavTreeRoot{} err := service.addAppLinks(&treeRoot, reqCtx) require.NoError(t, err) - require.Equal(t, "Apps", treeRoot.Children[0].Text) - require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[0].Text) + + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.NotNil(t, appsNode) + require.Equal(t, "Apps", appsNode.Text) + require.Len(t, appsNode.Children, 3) + require.Equal(t, testApp1.Name, appsNode.Children[0].Text) }) - t.Run("Should remove add default nav child when topnav is enabled", func(t *testing.T) { + t.Run("Should remove the default nav child (DefaultNav=true) when topnav is enabled and should set its URL to the plugin nav root", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) treeRoot := navtree.NavTreeRoot{} err := service.addAppLinks(&treeRoot, reqCtx) require.NoError(t, err) - require.Equal(t, "Apps", treeRoot.Children[0].Text) - require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[0].Text) - require.Equal(t, "Page2", treeRoot.Children[0].Children[0].Children[0].Text) + + app1Node := treeRoot.FindById("plugin-page-test-app1") + require.Len(t, app1Node.Children, 1) // The page include with DefaultNav=true gets removed + require.Equal(t, "/a/test-app1/catalog", app1Node.Url) + require.Equal(t, "Page2", app1Node.Children[0].Text) }) + // This can be done by using `[navigation.app_sections]` in the INI config t.Run("Should move apps that have specific nav id configured to correct section", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) service.navigationAppConfig = map[string]NavigationAppConfig{ @@ -140,76 +159,158 @@ func TestAddAppLinks(t *testing.T) { err := service.addAppLinks(&treeRoot, reqCtx) require.NoError(t, err) - require.Equal(t, "plugin-page-test-app1", treeRoot.Children[0].Children[0].Id) + + // Check if the plugin gets moved over to the "Admin" section + adminNode := treeRoot.FindById(navtree.NavIDAdmin) + require.NotNil(t, adminNode) + require.Len(t, adminNode.Children, 1) + require.Equal(t, "plugin-page-test-app1", adminNode.Children[0].Id) + + // Check if it is not under the "Apps" section anymore + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.NotNil(t, appsNode) + require.Len(t, appsNode.Children, 2) + require.Equal(t, "plugin-page-test-app2", appsNode.Children[0].Id) + require.Equal(t, "plugin-page-test-app3", appsNode.Children[1].Id) }) - t.Run("Should add monitoring section if plugin exists that wants to live there", func(t *testing.T) { + t.Run("Should only add a 'Monitoring' section if a plugin exists that wants to live there", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) + service.navigationAppConfig = map[string]NavigationAppConfig{} + + // Check if the Monitoring section is not there if no apps try to register to it + treeRoot := navtree.NavTreeRoot{} + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + monitoringNode := treeRoot.FindById(navtree.NavIDMonitoring) + require.Nil(t, monitoringNode) + + // It should appear and once an app tries to register to it + treeRoot = navtree.NavTreeRoot{} service.navigationAppConfig = map[string]NavigationAppConfig{ "test-app1": {SectionID: navtree.NavIDMonitoring}, } - - treeRoot := navtree.NavTreeRoot{} - - err := service.addAppLinks(&treeRoot, reqCtx) + err = service.addAppLinks(&treeRoot, reqCtx) require.NoError(t, err) - require.Equal(t, "Monitoring", treeRoot.Children[0].Text) - require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[0].Text) + monitoringNode = treeRoot.FindById(navtree.NavIDMonitoring) + require.NotNil(t, monitoringNode) + require.Len(t, monitoringNode.Children, 1) + require.Equal(t, "Test app1 name", monitoringNode.Children[0].Text) }) - t.Run("Should add Alerts and incidents section if plugin exists that wants to live there", func(t *testing.T) { + t.Run("Should add a 'Alerts and Incidents' section if a plugin exists that wants to live there", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) + service.navigationAppConfig = map[string]NavigationAppConfig{} + + // Check if the 'Alerts and Incidents' section is not there if no apps try to register to it + treeRoot := navtree.NavTreeRoot{} + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + alertsAndIncidentsNode := treeRoot.FindById(navtree.NavIDAlertsAndIncidents) + require.Nil(t, alertsAndIncidentsNode) + + // If there is no 'Alerting' node in the navigation (= alerting not enabled) then we don't auto-create the 'Alerts and Incidents' section + treeRoot = navtree.NavTreeRoot{} service.navigationAppConfig = map[string]NavigationAppConfig{ "test-app1": {SectionID: navtree.NavIDAlertsAndIncidents}, } - - treeRoot := navtree.NavTreeRoot{} - treeRoot.AddSection(&navtree.NavLink{Id: navtree.NavIDAlerting, Text: "Alerting"}) - - err := service.addAppLinks(&treeRoot, reqCtx) + err = service.addAppLinks(&treeRoot, reqCtx) require.NoError(t, err) - require.Equal(t, "Alerts & incidents", treeRoot.Children[0].Text) - require.Equal(t, "Alerting", treeRoot.Children[0].Children[0].Text) - require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[1].Text) + alertsAndIncidentsNode = treeRoot.FindById(navtree.NavIDAlertsAndIncidents) + require.Nil(t, alertsAndIncidentsNode) + + // It should appear and once an app tries to register to it and the `Alerting` nav node is present + treeRoot = navtree.NavTreeRoot{} + treeRoot.AddSection(&navtree.NavLink{Id: navtree.NavIDAlerting, Text: "Alerting"}) + service.navigationAppConfig = map[string]NavigationAppConfig{ + "test-app1": {SectionID: navtree.NavIDAlertsAndIncidents}, + } + err = service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + alertsAndIncidentsNode = treeRoot.FindById(navtree.NavIDAlertsAndIncidents) + require.NotNil(t, alertsAndIncidentsNode) + require.Len(t, alertsAndIncidentsNode.Children, 2) + require.Equal(t, "Alerting", alertsAndIncidentsNode.Children[0].Text) + require.Equal(t, "Test app1 name", alertsAndIncidentsNode.Children[1].Text) }) - t.Run("Should be able to control app sort order with SortWeight", func(t *testing.T) { + t.Run("Should be able to control app sort order with SortWeight (smaller SortWeight displayed first)", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) service.navigationAppConfig = map[string]NavigationAppConfig{ - "test-app2": {SectionID: navtree.NavIDMonitoring, SortWeight: 1}, - "test-app1": {SectionID: navtree.NavIDMonitoring, SortWeight: 2}, + "test-app2": {SectionID: navtree.NavIDMonitoring, SortWeight: 2}, + "test-app1": {SectionID: navtree.NavIDMonitoring, SortWeight: 3}, + "test-app3": {SectionID: navtree.NavIDMonitoring, SortWeight: 1}, } treeRoot := navtree.NavTreeRoot{} - err := service.addAppLinks(&treeRoot, reqCtx) - treeRoot.Sort() + monitoringNode := treeRoot.FindById(navtree.NavIDMonitoring) require.NoError(t, err) - require.Equal(t, "Test app2 name", treeRoot.Children[0].Children[0].Text) - require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[1].Text) + require.Equal(t, "Test app3 name", monitoringNode.Children[0].Text) + require.Equal(t, "Test app2 name", monitoringNode.Children[1].Text) + require.Equal(t, "Test app1 name", monitoringNode.Children[2].Text) }) t.Run("Should replace page from plugin", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav, featuremgmt.FlagDataConnectionsConsole) + service.navigationAppConfig = map[string]NavigationAppConfig{} service.navigationAppPathConfig = map[string]NavigationAppConfig{ "/connections/connect-data": {SectionID: "connections"}, } treeRoot := navtree.NavTreeRoot{} treeRoot.AddSection(service.buildDataConnectionsNavLink(reqCtx)) - require.Equal(t, "Connections", treeRoot.Children[0].Text) - require.Equal(t, "Connect Data", treeRoot.Children[0].Children[1].Text) - require.Equal(t, "connections-connect-data", treeRoot.Children[0].Children[1].Id) - require.Equal(t, "", treeRoot.Children[0].Children[1].PluginID) + connectionsNode := treeRoot.FindById("connections") + require.Equal(t, "Connections", connectionsNode.Text) + require.Equal(t, "Connect Data", connectionsNode.Children[1].Text) + require.Equal(t, "connections-connect-data", connectionsNode.Children[1].Id) // Original "Connect Data" page + require.Equal(t, "", connectionsNode.Children[1].PluginID) err := service.addAppLinks(&treeRoot, reqCtx) + + // Check if the standalone plugin page appears under the section where we registered it require.NoError(t, err) - require.Equal(t, "Connections", treeRoot.Children[0].Text) - require.Equal(t, "Connect Data", treeRoot.Children[0].Children[1].Text) - require.Equal(t, "standalone-plugin-page-/connections/connect-data", treeRoot.Children[0].Children[1].Id) - require.Equal(t, "test-app3", treeRoot.Children[0].Children[1].PluginID) + require.Equal(t, "Connections", connectionsNode.Text) + require.Equal(t, "Connect Data", connectionsNode.Children[1].Text) + require.Equal(t, "standalone-plugin-page-/connections/connect-data", connectionsNode.Children[1].Id) // Overridden "Connect Data" page + require.Equal(t, "test-app3", connectionsNode.Children[1].PluginID) + + // Check if the standalone plugin page does not appear under the app section anymore + // (Also checking if the Default Page got removed) + app3Node := treeRoot.FindById("plugin-page-test-app3") + require.NotNil(t, app3Node) + require.Len(t, app3Node.Children, 1) + require.Equal(t, "Random page", app3Node.Children[0].Text) + + // The plugin item should take the URL of the Default Nav + require.Equal(t, "/a/test-app3/default", app3Node.Url) + }) + + t.Run("Should not register pages under the app plugin section unless AddToNav=true", func(t *testing.T) { + service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav, featuremgmt.FlagDataConnectionsConsole) + service.navigationAppPathConfig = map[string]NavigationAppConfig{} // We don't configure it as a standalone plugin page + + treeRoot := navtree.NavTreeRoot{} + treeRoot.AddSection(service.buildDataConnectionsNavLink(reqCtx)) + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + + // The original core page should exist under the section + connectDataNode := treeRoot.FindById("connections-connect-data") + require.Equal(t, "connections-connect-data", connectDataNode.Id) + require.Equal(t, "", connectDataNode.PluginID) + + // The standalone plugin page should not be found in the navtree at all (as we didn't configure it) + standaloneConnectDataNode := treeRoot.FindById("standalone-plugin-page-/connections/connect-data") + require.Nil(t, standaloneConnectDataNode) + + // Only the pages that have `AddToNav=true` appear under the plugin navigation + app3Node := treeRoot.FindById("plugin-page-test-app3") + require.NotNil(t, app3Node) + require.Len(t, app3Node.Children, 1) // It should only have a single child now + require.Equal(t, "Random page", app3Node.Children[0].Text) }) } diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index bb2bfe9408e..0be95818648 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -65,8 +65,32 @@ class RootComponent extends Component { } function renderUnderRouter() { + const appPluginNavItem: NavModelItem = { + text: 'App', + id: 'plugin-page-app', + url: '/a/plugin-page-app', + children: [ + { + text: 'Page 1', + url: '/a/plugin-page-app/page-1', + }, + { + text: 'Page 2', + url: '/a/plugin-page-app/page-2', + }, + ], + }; + + const appsSection = { + text: 'apps', + id: 'apps', + children: [appPluginNavItem], + }; + + appPluginNavItem.parentItem = appsSection; + const store = configureStore(); - const route = { component: AppRootPage }; + const route = { component: () => }; locationService.push('/a/my-awesome-plugin'); render( diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index 8469224964f..40e465cd1d9 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -2,16 +2,14 @@ import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; -import { createSelector } from 'reselect'; +import { useLocation, useRouteMatch, useParams } from 'react-router-dom'; -import { AppEvents, AppPlugin, AppPluginMeta, KeyValue, NavModel, PluginType } from '@grafana/data'; +import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data'; import { config } from '@grafana/runtime'; import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { appEvents } from 'app/core/core'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { StoreState, useSelector } from 'app/types'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; @@ -19,47 +17,49 @@ import { buildPluginSectionNav } from '../utils'; import { buildPluginPageContext, PluginPageContext } from './PluginPageContext'; -interface RouteParams { +interface Props { + // The ID of the plugin we would like to load and display pluginId: string; + // The root navModelItem for the plugin (root = lives directly under 'home') + pluginNavSection: NavModelItem; } -interface Props extends GrafanaRouteComponentProps {} - interface State { loading: boolean; plugin?: AppPlugin | null; + // Used to display a tab navigation (used before the new Top Nav) pluginNav: NavModel | null; } const initialState: State = { loading: true, pluginNav: null, plugin: null }; -export function AppRootPage({ match, queryParams, location }: Props) { +export function AppRootPage({ pluginId, pluginNavSection }: Props) { + const match = useRouteMatch(); + const queryParams = useParams(); + const location = useLocation(); const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const portalNode = useMemo(() => createHtmlPortalNode(), []); + const currentUrl = config.appSubUrl + location.pathname + location.search; const { plugin, loading, pluginNav } = state; - const sectionNav = useSelector( - createSelector(getNavIndex, (navIndex) => - buildPluginSectionNav(location, pluginNav, navIndex, match.params.pluginId) - ) - ); - const context = useMemo(() => buildPluginPageContext(sectionNav), [sectionNav]); + const navModel = buildPluginSectionNav(pluginNavSection, pluginNav, currentUrl); + const context = useMemo(() => buildPluginPageContext(navModel), [navModel]); useEffect(() => { - loadAppPlugin(match.params.pluginId, dispatch); - }, [match.params.pluginId]); + loadAppPlugin(pluginId, dispatch); + }, [pluginId]); const onNavChanged = useCallback( (newPluginNav: NavModel) => dispatch(stateSlice.actions.changeNav(newPluginNav)), [] ); - if (!plugin || match.params.pluginId !== plugin.meta.id) { - return {loading && }; + if (!plugin || pluginId !== plugin.meta.id) { + return {loading && }; } if (!plugin.root) { return ( - +
No root app page component found
); @@ -70,7 +70,7 @@ export function AppRootPage({ match, queryParams, location }: Props) { meta={plugin.meta} basename={match.url} onNavChanged={onNavChanged} - query={queryParams as KeyValue} + query={queryParams} path={location.pathname} /> ); @@ -82,8 +82,8 @@ export function AppRootPage({ match, queryParams, location }: Props) { return ( <> {pluginRoot} - {sectionNav ? ( - + {navModel ? ( + @@ -144,10 +144,6 @@ async function loadAppPlugin(pluginId: string, dispatch: React.Dispatch id.startsWith('standalone-plugin-page-/'); + const isPluginNavModelItem = (model: NavModelItem): model is PluginNavModelItem => + 'pluginId' in model && 'id' in model; + + return Object.values(navIndex) + .filter(isPluginNavModelItem) + .map((navItem) => { + const pluginNavSection = getRootSectionForNode(navItem); + const appPluginUrl = `/a/${navItem.pluginId}`; + const path = isStandalonePluginPage(navItem.id) ? navItem.url || appPluginUrl : appPluginUrl; // Only standalone pages can use core URLs, otherwise we fall back to "/a/:pluginId" + + return { + path, + exact: false, // route everything under this path to the plugin, so it can define more routes under this path + component: () => , + }; + }); +} + +interface PluginNavModelItem extends Omit { + pluginId: string; + id: string; +} diff --git a/public/app/features/plugins/utils.test.ts b/public/app/features/plugins/utils.test.ts index dd6e16f75b1..bca1665ffb6 100644 --- a/public/app/features/plugins/utils.test.ts +++ b/public/app/features/plugins/utils.test.ts @@ -1,6 +1,4 @@ -import { Location as HistoryLocation } from 'history'; - -import { NavIndex, NavModelItem } from '@grafana/data'; +import { NavModelItem } from '@grafana/data'; import { config } from '@grafana/runtime'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; @@ -52,73 +50,36 @@ describe('buildPluginSectionNav', () => { app1.parentItem = appsSection; - const navIndex: NavIndex = { - apps: appsSection, - [app1.id!]: appsSection.children[0], - [standalonePluginPage.id]: standalonePluginPage, - [HOME_NAV_ID]: home, - }; - it('Should return pluginNav if topnav is disabled', () => { config.featureToggles.topnav = false; - const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {}, 'app1'); + const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1'); expect(result).toBe(pluginNav); }); it('Should return return section nav if topnav is enabled', () => { config.featureToggles.topnav = true; - const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex, 'app1'); + const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1'); expect(result?.main.text).toBe('apps'); }); it('Should set active page', () => { config.featureToggles.topnav = true; - const result = buildPluginSectionNav( - { pathname: '/a/plugin1/page2', search: '' } as HistoryLocation, - null, - navIndex, - 'app1' - ); + const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2'); expect(result?.main.children![0].children![1].active).toBe(true); expect(result?.node.text).toBe('page2'); }); it('Should set app section to active', () => { config.featureToggles.topnav = true; - const result = buildPluginSectionNav( - { pathname: '/a/plugin1', search: '' } as HistoryLocation, - null, - navIndex, - 'app1' - ); + const result = buildPluginSectionNav(appsSection, null, '/a/plugin1'); expect(result?.main.children![0].active).toBe(true); expect(result?.node.text).toBe('App1'); }); it('Should handle standalone page', () => { config.featureToggles.topnav = true; - const result = buildPluginSectionNav( - { pathname: '/a/app2/config', search: '' } as HistoryLocation, - pluginNav, - navIndex, - 'app2' - ); + const result = buildPluginSectionNav(adminSection, pluginNav, '/a/app2/config'); expect(result?.main.text).toBe('Admin'); expect(result?.node.text).toBe('Standalone page'); }); - - it('Should not throw error just return a root nav model without children for plugins that dont exist in navtree', () => { - config.featureToggles.topnav = true; - const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex, 'app3'); - expect(result?.main.id).toBe(HOME_NAV_ID); - }); - - it('Should throw error if app has no section', () => { - config.featureToggles.topnav = true; - app1.parentItem = undefined; - const action = () => { - buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex, 'app1'); - }; - expect(action).toThrowError(); - }); }); diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts index 2bdf2885b7c..c28a58e70df 100644 --- a/public/app/features/plugins/utils.ts +++ b/public/app/features/plugins/utils.ts @@ -1,9 +1,5 @@ -import { Location as HistoryLocation } from 'history'; - -import { GrafanaPlugin, NavIndex, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data'; +import { GrafanaPlugin, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { HOME_NAV_ID } from 'app/core/reducers/navModel'; -import { getRootSectionForNode } from 'app/core/selectors/navModel'; import { importPanelPluginFromMeta } from './importPanelPlugin'; import { getPluginSettings } from './pluginSettings'; @@ -35,26 +31,17 @@ export async function loadPlugin(pluginId: string): Promise { } export function buildPluginSectionNav( - location: HistoryLocation, + pluginNavSection: NavModelItem, pluginNav: NavModel | null, - navIndex: NavIndex, - pluginId: string + currentUrl: string ): NavModel | undefined { // When topnav is disabled we only just show pluginNav like before if (!config.featureToggles.topnav) { return pluginNav ?? undefined; } - let section = getPluginSection(location, navIndex, pluginId); - if (!section) { - return undefined; - } - // shallow clone as we set active flag - section = { ...section }; - - // If we have plugin nav don't set active page in section as it will cause double breadcrumbs - const currentUrl = config.appSubUrl + location.pathname + location.search; + let copiedPluginNavSection = { ...pluginNavSection }; let activePage: NavModelItem | undefined; function setPageToActive(page: NavModelItem, currentUrl: string): NavModelItem { @@ -75,7 +62,7 @@ export function buildPluginSectionNav( } // Find and set active page - section.children = (section?.children ?? []).map((child) => { + copiedPluginNavSection.children = (copiedPluginNavSection?.children ?? []).map((child) => { if (child.children) { return { ...setPageToActive(child, currentUrl), @@ -86,26 +73,5 @@ export function buildPluginSectionNav( return setPageToActive(child, currentUrl); }); - return { main: section, node: activePage ?? section }; -} - -// TODO make work for sub pages -export function getPluginSection(location: HistoryLocation, navIndex: NavIndex, pluginId: string): NavModelItem { - // First check if this page exist in navIndex using path, some plugin pages are not under their own section - const byPath = navIndex[`standalone-plugin-page-${location.pathname}`]; - if (byPath) { - return getRootSectionForNode(byPath); - } - - // Some plugins like cloud home don't have any precense in the navtree so we need to allow those - const navTreeNodeForPlugin = navIndex[`plugin-page-${pluginId}`]; - if (!navTreeNodeForPlugin) { - return navIndex[HOME_NAV_ID]; - } - - if (!navTreeNodeForPlugin.parentItem) { - throw new Error('Could not find plugin section'); - } - - return navTreeNodeForPlugin.parentItem; + return { main: copiedPluginNavSection, node: activePage ?? copiedPluginNavSection }; } diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 8179c38099c..938a38b7e0d 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -13,6 +13,7 @@ import { getRoutes as getDataConnectionsRoutes } from 'app/features/connections/ import { DATASOURCES_ROUTES } from 'app/features/datasources/constants'; import { getLiveRoutes } from 'app/features/live/pages/routes'; import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes'; +import { getAppPluginRoutes } from 'app/features/plugins/routes'; import { getProfileRoutes } from 'app/features/profile/routes'; import { AccessControlAction, DashboardRoutes } from 'app/types'; @@ -37,17 +38,13 @@ export function getAppRoutes(): RouteDescriptor[] { path: '/monitoring', component: () => , }, - { - path: '/a/:pluginId', - exact: true, - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage') - ), - }, ] : []; return [ + // Based on the Grafana configuration standalone plugin pages can even override and extend existing core pages, or they can register new routes under existing ones. + // In order to make it possible we need to register them first due to how `` is evaluating routes. (This will be unnecessary once/when we upgrade to React Router v6 and start using `` instead.) + ...getAppPluginRoutes(), { path: '/', pageClass: 'page-dashboard', @@ -208,14 +205,6 @@ export function getAppRoutes(): RouteDescriptor[] { ), }, ...topnavRoutes, - { - path: '/a/:pluginId', - exact: false, - // Someday * and will get a ReactRouter under that path! - component: SafeDynamicImport( - () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage') - ), - }, { path: '/org', component: SafeDynamicImport( From 1fb37b54b358233ffe5fcafe2bc5e9d7408dfd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 7 Nov 2022 15:32:02 +0100 Subject: [PATCH 084/926] Scenes: Enforce explicit accessibility modifiers (ESLint) (#58331) * Public test * Update * Update * revert * Added some public accessability modifiers * Force public acessability only for scenes/* folder * Fixes --- .eslintrc | 6 +++ .../scenes/components/NestedScene.tsx | 6 +-- .../app/features/scenes/components/Scene.tsx | 8 +-- .../scenes/components/SceneCanvasText.tsx | 4 +- .../scenes/components/SceneFlexLayout.tsx | 6 +-- .../scenes/components/ScenePanelRepeater.tsx | 8 +-- .../scenes/components/SceneTimePicker.tsx | 2 +- .../scenes/components/SceneToolbarButton.tsx | 4 +- .../features/scenes/components/VizPanel.tsx | 6 +-- .../features/scenes/core/SceneObjectBase.tsx | 51 +++++++++++-------- .../features/scenes/core/SceneTimeRange.tsx | 10 ++-- public/app/features/scenes/core/events.ts | 9 +--- .../scenes/editor/SceneEditManager.tsx | 10 ++-- .../scenes/querying/SceneQueryRunner.ts | 8 +-- .../scenes/services/UrlSyncManager.ts | 8 +-- .../scenes/variables/SceneVariableSet.ts | 2 +- 16 files changed, 78 insertions(+), 70 deletions(-) diff --git a/.eslintrc b/.eslintrc index 0ffbc3f1dbf..9535a971dd8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -44,6 +44,12 @@ "@typescript-eslint/no-redeclare": ["error"] }, "overrides": [ + { + "files": ["public/app/features/scenes/**/*.{ts,tsx}"], + "rules": { + "@typescript-eslint/explicit-member-accessibility": ["error", { "accessibility": "explicit" }] + } + }, { "files": ["packages/grafana-ui/src/components/uPlot/**/*.{ts,tsx}"], "rules": { diff --git a/public/app/features/scenes/components/NestedScene.tsx b/public/app/features/scenes/components/NestedScene.tsx index a6b027bce03..6ee7053f3a9 100644 --- a/public/app/features/scenes/components/NestedScene.tsx +++ b/public/app/features/scenes/components/NestedScene.tsx @@ -18,9 +18,9 @@ interface NestedSceneState extends SceneLayoutChildState { } export class NestedScene extends SceneObjectBase { - static Component = NestedSceneRenderer; + public static Component = NestedSceneRenderer; - onToggle = () => { + public onToggle = () => { this.setState({ isCollapsed: !this.state.isCollapsed, size: { @@ -31,7 +31,7 @@ export class NestedScene extends SceneObjectBase { }; /** Removes itself from its parent's children array */ - onRemove = () => { + public onRemove = () => { const parent = this.parent!; if ('children' in parent.state) { parent.setState({ diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx index 9e012430b53..2848d2d2a8e 100644 --- a/public/app/features/scenes/components/Scene.tsx +++ b/public/app/features/scenes/components/Scene.tsx @@ -18,15 +18,15 @@ interface SceneState extends SceneObjectStatePlain { } export class Scene extends SceneObjectBase { - static Component = SceneRenderer; - urlSyncManager?: UrlSyncManager; + public static Component = SceneRenderer; + private urlSyncManager?: UrlSyncManager; - activate() { + public activate() { super.activate(); this.urlSyncManager = new UrlSyncManager(this); } - deactivate() { + public deactivate() { super.deactivate(); this.urlSyncManager!.cleanUp(); } diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx index 6f4e354a101..aee61d0dc85 100644 --- a/public/app/features/scenes/components/SceneCanvasText.tsx +++ b/public/app/features/scenes/components/SceneCanvasText.tsx @@ -12,8 +12,8 @@ export interface SceneCanvasTextState extends SceneLayoutChildState { } export class SceneCanvasText extends SceneObjectBase { - static Editor = Editor; - static Component = ({ model }: SceneComponentProps) => { + public static Editor = Editor; + public static Component = ({ model }: SceneComponentProps) => { const { text, fontSize = 20, align = 'left' } = model.useState(); const style: CSSProperties = { diff --git a/public/app/features/scenes/components/SceneFlexLayout.tsx b/public/app/features/scenes/components/SceneFlexLayout.tsx index 0b9cfa149e7..cf6ec2e3dca 100644 --- a/public/app/features/scenes/components/SceneFlexLayout.tsx +++ b/public/app/features/scenes/components/SceneFlexLayout.tsx @@ -12,10 +12,10 @@ interface SceneFlexLayoutState extends SceneLayoutState { } export class SceneFlexLayout extends SceneObjectBase { - static Component = FlexLayoutRenderer; - static Editor = FlexLayoutEditor; + public static Component = FlexLayoutRenderer; + public static Editor = FlexLayoutEditor; - toggleDirection() { + public toggleDirection() { this.setState({ direction: this.state.direction === 'row' ? 'column' : 'row', }); diff --git a/public/app/features/scenes/components/ScenePanelRepeater.tsx b/public/app/features/scenes/components/ScenePanelRepeater.tsx index 89d6f62da73..26063a64211 100644 --- a/public/app/features/scenes/components/ScenePanelRepeater.tsx +++ b/public/app/features/scenes/components/ScenePanelRepeater.tsx @@ -17,10 +17,10 @@ interface RepeatOptions extends SceneObjectStatePlain { } export class ScenePanelRepeater extends SceneObjectBase { - activate(): void { + public activate(): void { super.activate(); - this.subs.add( + this._subs.add( this.getData().subscribeToState({ next: (data) => { if (data.data?.state === LoadingState.Done) { @@ -31,7 +31,7 @@ export class ScenePanelRepeater extends SceneObjectBase { ); } - performRepeat(data: PanelData) { + private performRepeat(data: PanelData) { // assume parent is a layout const firstChild = this.state.layout.state.children[0]!; const newChildren: SceneLayoutChild[] = []; @@ -53,7 +53,7 @@ export class ScenePanelRepeater extends SceneObjectBase { this.state.layout.setState({ children: newChildren }); } - static Component = ({ model, isEditing }: SceneComponentProps) => { + public static Component = ({ model, isEditing }: SceneComponentProps) => { const { layout } = model.useState(); return ; }; diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx index 09640d778c7..ad6b8281d9d 100644 --- a/public/app/features/scenes/components/SceneTimePicker.tsx +++ b/public/app/features/scenes/components/SceneTimePicker.tsx @@ -11,7 +11,7 @@ export interface SceneTimePickerState extends SceneObjectStatePlain { } export class SceneTimePicker extends SceneObjectBase { - static Component = SceneTimePickerRenderer; + public static Component = SceneTimePickerRenderer; } function SceneTimePickerRenderer({ model }: SceneComponentProps) { diff --git a/public/app/features/scenes/components/SceneToolbarButton.tsx b/public/app/features/scenes/components/SceneToolbarButton.tsx index 24f16ca44ef..abe4ed53298 100644 --- a/public/app/features/scenes/components/SceneToolbarButton.tsx +++ b/public/app/features/scenes/components/SceneToolbarButton.tsx @@ -11,7 +11,7 @@ export interface ToolbarButtonState extends SceneObjectStatePlain { } export class SceneToolbarButton extends SceneObjectBase { - static Component = ({ model }: SceneComponentProps) => { + public static Component = ({ model }: SceneComponentProps) => { const state = model.useState(); return ; @@ -24,7 +24,7 @@ export interface SceneToolbarInputState extends SceneObjectStatePlain { } export class SceneToolbarInput extends SceneObjectBase { - static Component = ({ model }: SceneComponentProps) => { + public static Component = ({ model }: SceneComponentProps) => { const state = model.useState(); return ( diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx index 83fe0978327..764a5741c1f 100644 --- a/public/app/features/scenes/components/VizPanel.tsx +++ b/public/app/features/scenes/components/VizPanel.tsx @@ -16,10 +16,10 @@ export interface VizPanelState extends SceneLayoutChildState { } export class VizPanel extends SceneObjectBase { - static Component = ScenePanelRenderer; - static Editor = VizPanelEditor; + public static Component = ScenePanelRenderer; + public static Editor = VizPanelEditor; - onSetTimeRange = (timeRange: AbsoluteTimeRange) => { + public onSetTimeRange = (timeRange: AbsoluteTimeRange) => { const sceneTimeRange = this.getTimeRange(); sceneTimeRange.setState({ raw: { diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index 8cfe7065720..d5871def18b 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -16,9 +16,9 @@ export abstract class SceneObjectBase impl private _events = new EventBusSrv(); protected _parent?: SceneObject; - protected subs = new Subscription(); + protected _subs = new Subscription(); - constructor(state: TState) { + public constructor(state: TState) { if (!state.key) { state.key = uuidv4(); } @@ -29,17 +29,17 @@ export abstract class SceneObjectBase impl } /** Current state */ - get state(): TState { + public get state(): TState { return this._state; } /** True if currently being active (ie displayed for visual objects) */ - get isActive(): boolean { + public get isActive(): boolean { return this._isActive; } /** Returns the parent, undefined for root object */ - get parent(): SceneObject | undefined { + public get parent(): SceneObject | undefined { return this._parent; } @@ -47,14 +47,14 @@ export abstract class SceneObjectBase impl * Used in render functions when rendering a SceneObject. * Wraps the component in an EditWrapper that handles edit mode */ - get Component(): SceneComponent { + public get Component(): SceneComponent { return SceneComponentWrapper; } /** * Temporary solution, should be replaced by declarative options */ - get Editor(): SceneComponent { + public get Editor(): SceneComponent { return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent; } @@ -77,18 +77,18 @@ export abstract class SceneObjectBase impl /** * Subscribe to the scene state subject **/ - subscribeToState(observerOrNext?: Partial>): Subscription { + public subscribeToState(observerOrNext?: Partial>): Subscription { return this._subject.subscribe(observerOrNext); } /** * Subscribe to the scene event **/ - subscribeToEvent(eventType: BusEventType, handler: BusEventHandler): Unsubscribable { + public subscribeToEvent(eventType: BusEventType, handler: BusEventHandler): Unsubscribable { return this._events.subscribe(eventType, handler); } - setState(update: Partial) { + public setState(update: Partial) { const prevState = this._state; this._state = { ...this._state, @@ -112,7 +112,7 @@ export abstract class SceneObjectBase impl /* * Publish an event and optionally bubble it up the scene **/ - publishEvent(event: BusEvent, bubble?: boolean) { + public publishEvent(event: BusEvent, bubble?: boolean) { this._events.publish(event); if (bubble && this.parent) { @@ -120,11 +120,14 @@ export abstract class SceneObjectBase impl } } - getRoot(): SceneObject { + public getRoot(): SceneObject { return !this._parent ? this : this._parent.getRoot(); } - activate() { + /** + * Called by the SceneComponentWrapper when the react component is mounted + */ + public activate() { this._isActive = true; const { $data, $variables } = this.state; @@ -138,7 +141,10 @@ export abstract class SceneObjectBase impl } } - deactivate(): void { + /** + * Called by the SceneComponentWrapper when the react component is unmounted + */ + public deactivate(): void { this._isActive = false; const { $data, $variables } = this.state; @@ -153,14 +159,17 @@ export abstract class SceneObjectBase impl // Clear subscriptions and listeners this._events.removeAllListeners(); - this.subs.unsubscribe(); - this.subs = new Subscription(); + this._subs.unsubscribe(); + this._subs = new Subscription(); this._subject.complete(); this._subject = new Subject(); } - useState() { + /** + * Utility hook to get and subscribe to state + */ + public useState() { // eslint-disable-next-line react-hooks/rules-of-hooks return useSceneObjectState(this); } @@ -168,7 +177,7 @@ export abstract class SceneObjectBase impl /** * Will walk up the scene object graph to the closest $timeRange scene object */ - getTimeRange(): SceneTimeRange { + public getTimeRange(): SceneTimeRange { const { $timeRange } = this.state; if ($timeRange) { return $timeRange; @@ -184,7 +193,7 @@ export abstract class SceneObjectBase impl /** * Will walk up the scene object graph to the closest $data scene object */ - getData(): SceneObject { + public getData(): SceneObject { const { $data } = this.state; if ($data) { return $data; @@ -200,7 +209,7 @@ export abstract class SceneObjectBase impl /** * Will walk up the scene object graph to the closest $editor scene object */ - getSceneEditor(): SceneEditor { + public getSceneEditor(): SceneEditor { const { $editor } = this.state; if ($editor) { return $editor; @@ -216,7 +225,7 @@ export abstract class SceneObjectBase impl /** * Will create new SceneItem with shalled cloned state, but all states items of type SceneObject are deep cloned */ - clone(withState?: Partial): this { + public clone(withState?: Partial): this { const clonedState = { ...this.state }; // Clone any SceneItems in state diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx index 595db0d864b..596b2337637 100644 --- a/public/app/features/scenes/core/SceneTimeRange.tsx +++ b/public/app/features/scenes/core/SceneTimeRange.tsx @@ -4,26 +4,26 @@ import { SceneObjectBase } from './SceneObjectBase'; import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types'; export class SceneTimeRange extends SceneObjectBase implements SceneObjectWithUrlSync { - onTimeRangeChange = (timeRange: TimeRange) => { + public onTimeRangeChange = (timeRange: TimeRange) => { this.setState(timeRange); }; - onRefresh = () => { + public onRefresh = () => { // TODO re-eval time range this.setState({ ...this.state }); }; - onIntervalChanged = (_: string) => {}; + public onIntervalChanged = (_: string) => {}; /** These url sync functions are only placeholders for something more sophisticated */ - getUrlState() { + public getUrlState() { return { from: this.state.raw.from, to: this.state.raw.to, } as any; } - updateFromUrl(values: UrlQueryMap) { + public updateFromUrl(values: UrlQueryMap) { // TODO } } diff --git a/public/app/features/scenes/core/events.ts b/public/app/features/scenes/core/events.ts index ab221616554..650bac14759 100644 --- a/public/app/features/scenes/core/events.ts +++ b/public/app/features/scenes/core/events.ts @@ -10,12 +10,5 @@ export interface SceneObjectStateChangedPayload { } export class SceneObjectStateChangedEvent extends BusEventWithPayload { - static type = 'scene-object-state-change'; -} - -export class SceneObjectActivedEvent extends BusEventWithPayload { - static type = 'scene-object-activated'; -} -export class SceneObjectDeactivatedEvent extends BusEventWithPayload { - static type = 'scene-object-deactivated'; + public static readonly type = 'scene-object-state-change'; } diff --git a/public/app/features/scenes/editor/SceneEditManager.tsx b/public/app/features/scenes/editor/SceneEditManager.tsx index bfa22349e71..f7fea9e0bc9 100644 --- a/public/app/features/scenes/editor/SceneEditManager.tsx +++ b/public/app/features/scenes/editor/SceneEditManager.tsx @@ -11,17 +11,17 @@ import { SceneObjectEditor } from './SceneObjectEditor'; import { SceneObjectTree } from './SceneObjectTree'; export class SceneEditManager extends SceneObjectBase implements SceneEditor { - static Component = SceneEditorRenderer; + public static Component = SceneEditorRenderer; - get Component(): SceneComponent { + public get Component(): SceneComponent { return SceneEditorRenderer; } - onMouseEnterObject(model: SceneObject) { + public onMouseEnterObject(model: SceneObject) { this.setState({ hoverObject: { ref: model } }); } - onMouseLeaveObject(model: SceneObject) { + public onMouseLeaveObject(model: SceneObject) { if (model.parent) { this.setState({ hoverObject: { ref: model.parent } }); } else { @@ -29,7 +29,7 @@ export class SceneEditManager extends SceneObjectBase implemen } } - onSelectObject(model: SceneObject) { + public onSelectObject(model: SceneObject) { this.setState({ selectedObject: { ref: model } }); } } diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index 81f644cfadb..fed9e351f59 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -31,12 +31,12 @@ export interface DataQueryExtended extends DataQuery { export class SceneQueryRunner extends SceneObjectBase { private querySub?: Unsubscribable; - activate() { + public activate() { super.activate(); const timeRange = this.getTimeRange(); - this.subs.add( + this._subs.add( timeRange.subscribeToState({ next: (timeRange) => { this.runWithTimeRange(timeRange); @@ -49,7 +49,7 @@ export class SceneQueryRunner extends SceneObjectBase { } } - deactivate(): void { + public deactivate(): void { super.deactivate(); if (this.querySub) { @@ -58,7 +58,7 @@ export class SceneQueryRunner extends SceneObjectBase { } } - runQueries() { + public runQueries() { const timeRange = this.getTimeRange(); this.runWithTimeRange(timeRange.state); } diff --git a/public/app/features/scenes/services/UrlSyncManager.ts b/public/app/features/scenes/services/UrlSyncManager.ts index bb20cacc082..23c5ad7e184 100644 --- a/public/app/features/scenes/services/UrlSyncManager.ts +++ b/public/app/features/scenes/services/UrlSyncManager.ts @@ -10,16 +10,16 @@ export class UrlSyncManager { private locationListenerUnsub: () => void; private stateChangeSub: Unsubscribable; - constructor(sceneRoot: SceneObject) { + public constructor(sceneRoot: SceneObject) { this.stateChangeSub = sceneRoot.subscribeToEvent(SceneObjectStateChangedEvent, this.onStateChanged); this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate); } - onLocationUpdate = (location: Location) => { + private onLocationUpdate = (location: Location) => { // TODO: find any scene object whose state we need to update }; - onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => { + private onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => { const changedObject = payload.changedObject; if ('getUrlState' in changedObject) { @@ -28,7 +28,7 @@ export class UrlSyncManager { } }; - cleanUp() { + public cleanUp() { this.stateChangeSub.unsubscribe(); this.locationListenerUnsub(); } diff --git a/public/app/features/scenes/variables/SceneVariableSet.ts b/public/app/features/scenes/variables/SceneVariableSet.ts index d7f90d8c777..c56c1f15e1b 100644 --- a/public/app/features/scenes/variables/SceneVariableSet.ts +++ b/public/app/features/scenes/variables/SceneVariableSet.ts @@ -8,7 +8,7 @@ import { SceneVariable, SceneVariables, SceneVariableSetState, SceneVariableStat export class TextBoxSceneVariable extends SceneObjectBase implements SceneVariable {} export class SceneVariableSet extends SceneObjectBase implements SceneVariables { - getVariableByName(name: string): SceneVariable | undefined { + public getVariableByName(name: string): SceneVariable | undefined { // TODO: Replace with index return this.state.variables.find((x) => x.state.name === name); } From 623de12e354c8f92fd49802a04bafe5970fba0e9 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 7 Nov 2022 09:35:29 -0500 Subject: [PATCH 085/926] Alerting: Create AlertInstanceKey in one place (#58278) * use method GetAlertInstanceKey * do not add key if error --- pkg/services/ngalert/state/manager.go | 21 ++++++++------------- pkg/services/ngalert/state/state.go | 9 +++++++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index f3a00b573e9..77d7c34e0cc 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -291,18 +291,13 @@ func (st *Manager) saveAlertStates(ctx context.Context, logger log.Logger, state instances := make([]ngModels.AlertInstance, 0, len(states)) for _, s := range states { - labels := ngModels.InstanceLabels(s.Labels) - _, hash, err := labels.StringAndHash() + key, err := s.GetAlertInstanceKey() if err != nil { logger.Error("Failed to create a key for alert state to save it to database. The state will be ignored ", "cacheID", s.CacheID, "error", err) continue } fields := ngModels.AlertInstance{ - AlertInstanceKey: ngModels.AlertInstanceKey{ - RuleOrgID: s.OrgID, - RuleUID: s.AlertRuleUID, - LabelsHash: hash, - }, + AlertInstanceKey: key, Labels: ngModels.InstanceLabels(s.Labels), CurrentState: ngModels.InstanceStateType(s.State.State.String()), CurrentReason: s.StateReason, @@ -352,13 +347,13 @@ func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Tim if _, ok := states[s.CacheID]; !ok && stateIsStale(evaluatedAt, s.LastEvaluationTime, alertRule.IntervalSeconds) { logger.Info("Removing stale state entry", "cacheID", s.CacheID, "state", s.State, "reason", s.StateReason) st.cache.deleteEntry(s.OrgID, s.AlertRuleUID, s.CacheID) - ilbs := ngModels.InstanceLabels(s.Labels) - _, labelsHash, err := ilbs.StringAndHash() - if err != nil { - logger.Error("Unable to get labelsHash", "error", err.Error(), s.AlertRuleUID) - } - toDelete = append(toDelete, ngModels.AlertInstanceKey{RuleOrgID: s.OrgID, RuleUID: s.AlertRuleUID, LabelsHash: labelsHash}) + key, err := s.GetAlertInstanceKey() + if err != nil { + logger.Error("Unable to get alert instance key to delete it from database. Ignoring", "error", err.Error()) + } else { + toDelete = append(toDelete, key) + } if s.State == eval.Alerting { oldState := s.State diff --git a/pkg/services/ngalert/state/state.go b/pkg/services/ngalert/state/state.go index bfd4237f27e..8f59cc6761a 100644 --- a/pkg/services/ngalert/state/state.go +++ b/pkg/services/ngalert/state/state.go @@ -73,6 +73,15 @@ func (a *State) GetRuleKey() models.AlertRuleKey { } } +func (a *State) GetAlertInstanceKey() (models.AlertInstanceKey, error) { + instanceLabels := models.InstanceLabels(a.Labels) + _, labelsHash, err := instanceLabels.StringAndHash() + if err != nil { + return models.AlertInstanceKey{}, err + } + return models.AlertInstanceKey{RuleOrgID: a.OrgID, RuleUID: a.AlertRuleUID, LabelsHash: labelsHash}, nil +} + // StateTransition describes the transition from one state to another. type StateTransition struct { *State From 1ba25b2baac428503caede803f47280f895e1168 Mon Sep 17 00:00:00 2001 From: Emil Tullstedt Date: Mon, 7 Nov 2022 15:52:26 +0100 Subject: [PATCH 086/926] Preferences: Create indices (#48356) --- pkg/services/sqlstore/migrations/preferences_mig.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/services/sqlstore/migrations/preferences_mig.go b/pkg/services/sqlstore/migrations/preferences_mig.go index 6f36a5cf53b..f348ee9bc27 100644 --- a/pkg/services/sqlstore/migrations/preferences_mig.go +++ b/pkg/services/sqlstore/migrations/preferences_mig.go @@ -55,4 +55,7 @@ func addPreferencesMigrations(mg *Migrator) { // change column type of preferences.json_data mg.AddMigration("alter preferences.json_data to mediumtext v1", NewRawSQLMigration(""). Mysql("ALTER TABLE preferences MODIFY json_data MEDIUMTEXT;")) + + mg.AddMigration("Add preferences index org_id", NewAddIndexMigration(preferencesV2, preferencesV2.Indices[0])) + mg.AddMigration("Add preferences index user_id", NewAddIndexMigration(preferencesV2, preferencesV2.Indices[1])) } From 6bc8ec0f9bbcdc48e6865e8318eb7fb8de7068ab Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Mon, 7 Nov 2022 09:03:42 -0600 Subject: [PATCH 087/926] fix thanos semver string (#58335) --- public/app/plugins/datasource/prometheus/datasource.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/prometheus/datasource.tsx b/public/app/plugins/datasource/prometheus/datasource.tsx index f739c898052..0cd3b4819a4 100644 --- a/public/app/plugins/datasource/prometheus/datasource.tsx +++ b/public/app/plugins/datasource/prometheus/datasource.tsx @@ -157,7 +157,7 @@ export class PrometheusDatasource this._isDatasourceVersionGreaterOrEqualTo('1.11.0', PromApplication.Cortex) || // https://github.com/thanos-io/thanos/pull/3566 //https://github.com/thanos-io/thanos/releases/tag/v0.18.0 - this._isDatasourceVersionGreaterOrEqualTo('0.18', PromApplication.Thanos) + this._isDatasourceVersionGreaterOrEqualTo('0.18.0', PromApplication.Thanos) ); } From 89eba7a1087b96ae75d9e30eb26dce18400c8c49 Mon Sep 17 00:00:00 2001 From: Emil Tullstedt Date: Mon, 7 Nov 2022 16:14:41 +0100 Subject: [PATCH 088/926] Server: Write internal server error on missing write (#57813) --- pkg/util/errutil/errhttp/writer.go | 14 +++++--------- pkg/web/context.go | 11 ++++++++++- pkg/web/context_test.go | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/pkg/util/errutil/errhttp/writer.go b/pkg/util/errutil/errhttp/writer.go index 165799a1d6b..5bbd9aede2e 100644 --- a/pkg/util/errutil/errhttp/writer.go +++ b/pkg/util/errutil/errhttp/writer.go @@ -8,12 +8,11 @@ import ( "reflect" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/util/errutil" ) var ErrNonGrafanaError = errutil.NewBase(errutil.StatusInternal, "core.MalformedError") -var defaultLogger = log.New("request-errors") +var defaultLogger = log.New("requestErrors") // ErrorOptions is a container for functional options passed to [Write]. type ErrorOptions struct { @@ -23,9 +22,9 @@ type ErrorOptions struct { // Write writes an error to the provided [http.ResponseWriter] with the // appropriate HTTP status and JSON payload from [errutil.Error]. -// Write also logs the provided error to either the contextlogger, -// the "request-errors" logger, or the logger provided as a functional -// option using [WithLogger]. +// Write also logs the provided error to either the "request-errors" +// logger, or the logger provided as a functional option using +// [WithLogger]. // When passing errors that are not [errors.As] compatible with // [errutil.Error], [ErrNonGrafanaError] will be used to create a // generic 500 Internal Server Error payload by default, this is @@ -45,8 +44,8 @@ func Write(ctx context.Context, err error, w http.ResponseWriter, opts ...func(E logError(ctx, gErr, opt) pub := gErr.Public() - w.WriteHeader(pub.StatusCode) w.Header().Add("Content-Type", "application/json") + w.WriteHeader(pub.StatusCode) err = json.NewEncoder(w).Encode(pub) if err != nil { defaultLogger.FromContext(ctx).Error("error while writing error", "error", err) @@ -68,9 +67,6 @@ func WithLogger(opt ErrorOptions, logger log.Logger) ErrorOptions { func logError(ctx context.Context, e errutil.Error, opt ErrorOptions) { var logger log.Logger = defaultLogger - if reqCtx := contexthandler.FromContext(ctx); reqCtx != nil && reqCtx.Logger != nil { - logger = reqCtx.Logger - } if opt.logger != nil { logger = opt.logger } diff --git a/pkg/web/context.go b/pkg/web/context.go index 897aef9db64..ca878c7ccd4 100644 --- a/pkg/web/context.go +++ b/pkg/web/context.go @@ -22,6 +22,9 @@ import ( "net/url" "strconv" "strings" + + "github.com/grafana/grafana/pkg/util/errutil" + "github.com/grafana/grafana/pkg/util/errutil/errhttp" ) // Context represents the runtime context of current request of Macaron instance. @@ -34,6 +37,8 @@ type Context struct { template *template.Template } +var errMissingWrite = errutil.NewBase(errutil.StatusInternal, "web.missingWrite") + func (ctx *Context) run() { h := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) for i := len(ctx.mws) - 1; i >= 0; i-- { @@ -47,7 +52,11 @@ func (ctx *Context) run() { // This indicates nearly always that a middleware is misbehaving and not calling its next.ServeHTTP(). // In rare cases where a blank http.StatusOK without any body is wished, explicitly state that using w.WriteStatus(http.StatusOK) if !rw.Written() { - panic("chain did not write HTTP response") + errhttp.Write( + ctx.Req.Context(), + errMissingWrite.Errorf("chain did not write HTTP response: %s", ctx.Req.URL.Path), + rw, + ) } } diff --git a/pkg/web/context_test.go b/pkg/web/context_test.go index 1144df15bfd..da9c5edaa14 100644 --- a/pkg/web/context_test.go +++ b/pkg/web/context_test.go @@ -2,8 +2,11 @@ package web import ( "net/http" + "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" + "github.com/grafana/grafana/pkg/infra/log" ) @@ -94,3 +97,17 @@ func TestContext_RemoteAddr(t *testing.T) { }) } } + +func TestContext_noHandler(t *testing.T) { + recorder := httptest.NewRecorder() + + method := http.MethodGet + c := &Context{ + Req: httptest.NewRequest(method, "/", nil), + Resp: NewResponseWriter(method, recorder), + } + + c.run() + + assert.Equal(t, http.StatusInternalServerError, recorder.Code) +} From bc280d07492f66456e47eb262f935c933312b1ed Mon Sep 17 00:00:00 2001 From: Si Mon <85333972+siiimooon@users.noreply.github.com> Date: Mon, 7 Nov 2022 16:53:42 +0100 Subject: [PATCH 089/926] Datasource Loki: preserve header `X-ID-Token` (#57878) --- pkg/tsdb/loki/auth_test.go | 12 +++++++++++- pkg/tsdb/loki/loki.go | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/tsdb/loki/auth_test.go b/pkg/tsdb/loki/auth_test.go index f7e8da03155..caaee66b6ef 100644 --- a/pkg/tsdb/loki/auth_test.go +++ b/pkg/tsdb/loki/auth_test.go @@ -53,7 +53,7 @@ func TestOauthForwardIdentity(t *testing.T) { auth bool cookie bool }{ - {name: "when auth header exists => add auth header", auth: true, cookie: false}, + {name: "when auth headers exist => add auth headers", auth: true, cookie: false}, {name: "when cookie header exists => add cookie header", auth: false, cookie: true}, {name: "when cookie&auth headers exist => add cookie&auth headers", auth: true, cookie: true}, {name: "when no header exists => do not add headers", auth: false, cookie: false}, @@ -63,6 +63,8 @@ func TestOauthForwardIdentity(t *testing.T) { authValue := "auth" cookieName := "Cookie" cookieValue := "a=1" + idTokenName := "X-ID-Token" + idTokenValue := "idtoken" for _, test := range tt { t.Run("QueryData: "+test.name, func(t *testing.T) { @@ -91,10 +93,13 @@ func TestOauthForwardIdentity(t *testing.T) { // as an array authValues := req.Header.Values(authName) cookieValues := req.Header.Values(cookieName) + idTokenValues := req.Header.Values(idTokenName) if test.auth { require.Equal(t, []string{authValue}, authValues) + require.Equal(t, []string{idTokenValue}, idTokenValues) } else { require.Len(t, authValues, 0) + require.Len(t, idTokenValues, 0) } if test.cookie { require.Equal(t, []string{cookieValue}, cookieValues) @@ -115,6 +120,7 @@ func TestOauthForwardIdentity(t *testing.T) { if test.auth { req.Headers[authName] = authValue + req.Headers[idTokenName] = idTokenValue } if test.cookie { @@ -146,13 +152,16 @@ func TestOauthForwardIdentity(t *testing.T) { clientUsed = true authValues := req.Header.Values(authName) cookieValues := req.Header.Values(cookieName) + idTokenValues := req.Header.Values(idTokenName) // we need to check for "header does not exist", // and the only way i can find is to get the values // as an array if test.auth { require.Equal(t, []string{authValue}, authValues) + require.Equal(t, []string{idTokenValue}, idTokenValues) } else { require.Len(t, authValues, 0) + require.Len(t, idTokenValues, 0) } if test.cookie { require.Equal(t, []string{cookieValue}, cookieValues) @@ -169,6 +178,7 @@ func TestOauthForwardIdentity(t *testing.T) { if test.auth { req.Headers[authName] = []string{authValue} + req.Headers[idTokenName] = []string{idTokenValue} } if test.cookie { req.Headers[cookieName] = []string{cookieValue} diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index a86e2421f1d..49c1f46b909 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -130,6 +130,10 @@ func getAuthHeadersForCallResource(headers map[string][]string) map[string]strin data["Cookie"] = cookie } + if idToken := arrayHeaderFirstValue(headers["X-ID-Token"]); idToken != "" { + data["X-ID-Token"] = idToken + } + return data } From db1fd10ff13d936665210e0c8a86c50ecae09998 Mon Sep 17 00:00:00 2001 From: Neel <47709856+neel1996@users.noreply.github.com> Date: Mon, 7 Nov 2022 21:33:25 +0530 Subject: [PATCH 090/926] Alerting: Append org ID to alert notification URLs (#57123) --- pkg/services/ngalert/models/alert_rule.go | 1 + .../channels/default_template_test.go | 30 +++++++++---------- .../notifier/channels/template_data.go | 27 +++++++++++++++++ pkg/services/ngalert/schedule/compat.go | 5 ++++ .../alerting/api_notification_channel_test.go | 1 + 5 files changed, 49 insertions(+), 15 deletions(-) diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 1e8abde2fdb..1572cd7b850 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -87,6 +87,7 @@ const ( // Annotations are actually a set of labels, so technically this is the label name of an annotation. DashboardUIDAnnotation = "__dashboardUid__" PanelIDAnnotation = "__panelId__" + OrgIDAnnotation = "__orgId__" // This isn't a hard-coded secret token, hence the nolint. //nolint:gosec diff --git a/pkg/services/ngalert/notifier/channels/default_template_test.go b/pkg/services/ngalert/notifier/channels/default_template_test.go index 55fae4b6198..596389ebbbc 100644 --- a/pkg/services/ngalert/notifier/channels/default_template_test.go +++ b/pkg/services/ngalert/notifier/channels/default_template_test.go @@ -20,11 +20,11 @@ func TestDefaultTemplateString(t *testing.T) { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, Annotations: model.LabelSet{ - "ann1": "annv1", "__dashboardUid__": "dbuid123", "__panelId__": "puid123", "__values__": "{\"A\": 1234}", "__value_string__": "1234", + "ann1": "annv1", "__orgId__": "1", "__dashboardUid__": "dbuid123", "__panelId__": "puid123", "__values__": "{\"A\": 1234}", "__value_string__": "1234", }, StartsAt: time.Now(), EndsAt: time.Now().Add(1 * time.Hour), - GeneratorURL: "http://localhost/alert1", + GeneratorURL: "http://localhost/alert1?orgId=1", }, }, { // Firing without dashboard and panel ID. Alert: model.Alert{ @@ -38,7 +38,7 @@ func TestDefaultTemplateString(t *testing.T) { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val3"}, Annotations: model.LabelSet{ - "ann1": "annv3", "__dashboardUid__": "dbuid456", "__panelId__": "puid456", "__values__": "{\"A\": 1234}", "__value_string__": "1234", + "ann1": "annv3", "__orgId__": "1", "__dashboardUid__": "dbuid456", "__panelId__": "puid456", "__values__": "{\"A\": 1234}", "__value_string__": "1234", }, StartsAt: time.Now().Add(-1 * time.Hour), EndsAt: time.Now().Add(-30 * time.Minute), @@ -97,10 +97,10 @@ Labels: - lbl1 = val1 Annotations: - ann1 = annv1 -Source: http://localhost/alert1 +Source: http://localhost/alert1?orgId=1 Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 -Dashboard: http://localhost/grafana/d/dbuid123 -Panel: http://localhost/grafana/d/dbuid123?viewPanel=puid123 +Dashboard: http://localhost/grafana/d/dbuid123?orgId=1 +Panel: http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123 Value: A=1234 Labels: @@ -120,10 +120,10 @@ Labels: - lbl1 = val3 Annotations: - ann1 = annv3 -Source: http://localhost/alert3 +Source: http://localhost/alert3?orgId=1 Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3 -Dashboard: http://localhost/grafana/d/dbuid456 -Panel: http://localhost/grafana/d/dbuid456?viewPanel=puid456 +Dashboard: http://localhost/grafana/d/dbuid456?orgId=1 +Panel: http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456 Value: A=1234 Labels: @@ -147,13 +147,13 @@ Labels: Annotations: - ann1 = annv1 -Source: [http://localhost/alert1](http://localhost/alert1) +Source: [http://localhost/alert1?orgId=1](http://localhost/alert1?orgId=1) Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1) -Dashboard: [http://localhost/grafana/d/dbuid123](http://localhost/grafana/d/dbuid123) +Dashboard: [http://localhost/grafana/d/dbuid123?orgId=1](http://localhost/grafana/d/dbuid123?orgId=1) -Panel: [http://localhost/grafana/d/dbuid123?viewPanel=puid123](http://localhost/grafana/d/dbuid123?viewPanel=puid123) +Panel: [http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123](http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123) @@ -182,13 +182,13 @@ Labels: Annotations: - ann1 = annv3 -Source: [http://localhost/alert3](http://localhost/alert3) +Source: [http://localhost/alert3?orgId=1](http://localhost/alert3?orgId=1) Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3) -Dashboard: [http://localhost/grafana/d/dbuid456](http://localhost/grafana/d/dbuid456) +Dashboard: [http://localhost/grafana/d/dbuid456?orgId=1](http://localhost/grafana/d/dbuid456?orgId=1) -Panel: [http://localhost/grafana/d/dbuid456?viewPanel=puid456](http://localhost/grafana/d/dbuid456?viewPanel=puid456) +Panel: [http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456](http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456) diff --git a/pkg/services/ngalert/notifier/channels/template_data.go b/pkg/services/ngalert/notifier/channels/template_data.go index 4c597272fe7..c809c337c54 100644 --- a/pkg/services/ngalert/notifier/channels/template_data.go +++ b/pkg/services/ngalert/notifier/channels/template_data.go @@ -89,6 +89,25 @@ func extendAlert(alert template.Alert, externalURL string, logger log.Logger) *E u.RawQuery = "viewPanel=" + panelId extended.PanelURL = u.String() } + + generatorUrl, err := url.Parse(extended.GeneratorURL) + if err != nil { + logger.Debug("failed to parse generator URL while extending template data", "url", extended.GeneratorURL, "err", err.Error()) + return extended + } + + dashboardUrl, err := url.Parse(extended.DashboardURL) + if err != nil { + logger.Debug("failed to parse dashboard URL while extending template data", "url", extended.DashboardURL, "err", err.Error()) + return extended + } + + orgId := alert.Annotations[ngmodels.OrgIDAnnotation] + if len(orgId) > 0 { + extended.DashboardURL = setOrgIdQueryParam(dashboardUrl, orgId) + extended.PanelURL = setOrgIdQueryParam(u, orgId) + extended.GeneratorURL = setOrgIdQueryParam(generatorUrl, orgId) + } } if alert.Annotations != nil { @@ -123,6 +142,14 @@ func extendAlert(alert template.Alert, externalURL string, logger log.Logger) *E return extended } +func setOrgIdQueryParam(url *url.URL, orgId string) string { + q := url.Query() + q.Set("orgId", orgId) + url.RawQuery = q.Encode() + + return url.String() +} + func ExtendData(data *template.Data, logger log.Logger) *ExtendedData { alerts := []ExtendedAlert{} diff --git a/pkg/services/ngalert/schedule/compat.go b/pkg/services/ngalert/schedule/compat.go index ffa6b381444..5e96e052d69 100644 --- a/pkg/services/ngalert/schedule/compat.go +++ b/pkg/services/ngalert/schedule/compat.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "path" + "strconv" "time" "github.com/benbjohnson/clock" @@ -55,6 +56,10 @@ func stateToPostableAlert(alertState *state.State, appURL *url.URL) *models.Post nA[ngModels.StateReasonAnnotation] = alertState.StateReason } + if alertState.OrgID != 0 { + nA[ngModels.OrgIDAnnotation] = strconv.FormatInt(alertState.OrgID, 10) + } + var urlStr string if uid := nL[ngModels.RuleUIDLabel]; len(uid) > 0 && appURL != nil { u := *appURL diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index c520fa07bf4..35b2477d6b8 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2619,6 +2619,7 @@ var expNonEmailNotifications = map[string][]string{ "grafana_folder": "default" }, "annotations": { + "__orgId__":"1", "__values__": "{\"A\":1}", "__value_string__": "[ var='A' labels={} value=1 ]" }, From 3621cf5a1287072d7f40af4b8815ca597d37a97e Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 7 Nov 2022 11:03:53 -0500 Subject: [PATCH 091/926] Alerting: Update handling of stale state (#58276) * delete all stale states in one lock * do not use touched states to detect stale rely only on LastEvaluationTime maintained correctly * fix tests to use correct eval time * delete unused method --- .../ngalert/schedule/schedule_unit_test.go | 4 +- pkg/services/ngalert/state/cache.go | 31 ++++--- pkg/services/ngalert/state/manager.go | 84 +++++++++---------- 3 files changed, 64 insertions(+), 55 deletions(-) diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 00dfdc475b4..15dfec3190e 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -169,7 +169,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { sch, _, _, _ := createSchedule(make(chan time.Time), nil) rule := models.AlertRuleGen()() - _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil) + _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) expectedStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) require.NotEmpty(t, expectedStates) @@ -189,7 +189,7 @@ func TestSchedule_ruleRoutine(t *testing.T) { sch, _, _, _ := createSchedule(make(chan time.Time), nil) rule := models.AlertRuleGen()() - _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil) + _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) ctx, cancel := util.WithCancelCause(context.Background()) diff --git a/pkg/services/ngalert/state/cache.go b/pkg/services/ngalert/state/cache.go index 56c6d600d39..e3d3d0d52e5 100644 --- a/pkg/services/ngalert/state/cache.go +++ b/pkg/services/ngalert/state/cache.go @@ -156,6 +156,27 @@ func (rs *ruleStates) expandRuleLabelsAndAnnotations(ctx context.Context, log lo return expand(alertRule.Labels), expand(alertRule.Annotations) } +func (rs *ruleStates) deleteStates(predicate func(s *State) bool) []*State { + deleted := make([]*State, 0) + for id, state := range rs.states { + if predicate(state) { + delete(rs.states, id) + deleted = append(deleted, state) + } + } + return deleted +} + +func (c *cache) deleteRuleStates(ruleKey ngModels.AlertRuleKey, predicate func(s *State) bool) []*State { + c.mtxStates.Lock() + defer c.mtxStates.Unlock() + ruleStates, ok := c.states[ruleKey.OrgID][ruleKey.UID] + if ok { + return ruleStates.deleteStates(predicate) + } + return nil +} + func (c *cache) setAllStates(newStates map[int64]map[string]*ruleStates) { c.mtxStates.Lock() defer c.mtxStates.Unlock() @@ -283,13 +304,3 @@ func mergeLabels(a, b data.Labels) data.Labels { } return newLbs } - -func (c *cache) deleteEntry(orgID int64, alertRuleUID, cacheID string) { - c.mtxStates.Lock() - defer c.mtxStates.Unlock() - ruleStates, ok := c.states[orgID][alertRuleUID] - if !ok { - return - } - delete(ruleStates.states, cacheID) -} diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 77d7c34e0cc..3df70379803 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -172,13 +172,12 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time logger := st.log.FromContext(ctx) logger.Debug("State manager processing evaluation results", "resultCount", len(results)) var states []StateTransition - processedResults := make(map[string]*State, len(results)) + for _, result := range results { s := st.setNextState(ctx, alertRule, result, extraLabels, logger) states = append(states, s) - processedResults[s.State.CacheID] = s.State } - resolvedStates := st.staleResultsHandler(ctx, evaluatedAt, alertRule, processedResults, logger) + resolvedStates := st.staleResultsHandler(ctx, evaluatedAt, alertRule, logger) st.saveAlertStates(ctx, logger, states...) @@ -333,58 +332,57 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { } } -func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, states map[string]*State, logger log.Logger) []StateTransition { +func (st *Manager) staleResultsHandler(ctx context.Context, evaluatedAt time.Time, alertRule *ngModels.AlertRule, logger log.Logger) []StateTransition { // If we are removing two or more stale series it makes sense to share the resolved image as the alert rule is the same. // TODO: We will need to change this when we support images without screenshots as each series will have a different image var resolvedImage *ngModels.Image var resolvedStates []StateTransition - allStates := st.GetStatesForRuleUID(alertRule.OrgID, alertRule.UID) + staleStates := st.cache.deleteRuleStates(alertRule.GetKey(), func(s *State) bool { + return stateIsStale(evaluatedAt, s.LastEvaluationTime, alertRule.IntervalSeconds) + }) + toDelete := make([]ngModels.AlertInstanceKey, 0) - for _, s := range allStates { - // Is the cached state in our recently processed results? If not, is it stale? - if _, ok := states[s.CacheID]; !ok && stateIsStale(evaluatedAt, s.LastEvaluationTime, alertRule.IntervalSeconds) { - logger.Info("Removing stale state entry", "cacheID", s.CacheID, "state", s.State, "reason", s.StateReason) - st.cache.deleteEntry(s.OrgID, s.AlertRuleUID, s.CacheID) + for _, s := range staleStates { + logger.Info("Detected stale state entry", "cacheID", s.CacheID, "state", s.State, "reason", s.StateReason) - key, err := s.GetAlertInstanceKey() - if err != nil { - logger.Error("Unable to get alert instance key to delete it from database. Ignoring", "error", err.Error()) - } else { - toDelete = append(toDelete, key) + key, err := s.GetAlertInstanceKey() + if err != nil { + logger.Error("Unable to get alert instance key to delete it from database. Ignoring", "error", err.Error()) + } else { + toDelete = append(toDelete, key) + } + + if s.State == eval.Alerting { + oldState := s.State + oldReason := s.StateReason + + s.State = eval.Normal + s.StateReason = ngModels.StateReasonMissingSeries + s.EndsAt = evaluatedAt + s.Resolved = true + s.LastEvaluationTime = evaluatedAt + record := StateTransition{ + State: s, + PreviousState: oldState, + PreviousStateReason: oldReason, } - if s.State == eval.Alerting { - oldState := s.State - oldReason := s.StateReason - - s.State = eval.Normal - s.StateReason = ngModels.StateReasonMissingSeries - s.EndsAt = evaluatedAt - s.Resolved = true - s.LastEvaluationTime = evaluatedAt - record := StateTransition{ - State: s, - PreviousState: oldState, - PreviousStateReason: oldReason, + // If there is no resolved image for this rule then take one + if resolvedImage == nil { + image, err := takeImage(ctx, st.imageService, alertRule) + if err != nil { + logger.Warn("Failed to take an image", + "dashboard", alertRule.DashboardUID, + "panel", alertRule.PanelID, + "error", err) + } else if image != nil { + resolvedImage = image } - - // If there is no resolved image for this rule then take one - if resolvedImage == nil { - image, err := takeImage(ctx, st.imageService, alertRule) - if err != nil { - logger.Warn("Failed to take an image", - "dashboard", alertRule.DashboardUID, - "panel", alertRule.PanelID, - "error", err) - } else if image != nil { - resolvedImage = image - } - } - s.Image = resolvedImage - resolvedStates = append(resolvedStates, record) } + s.Image = resolvedImage + resolvedStates = append(resolvedStates, record) } } From b47230623944f01aa6e2367913e2f7596ff284bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Mon, 7 Nov 2022 17:04:53 +0100 Subject: [PATCH 092/926] MSSql/Postgres: Fix visual query editor filter disappearing (#58248) --- public/app/plugins/datasource/mssql/datasource.ts | 3 +++ public/app/plugins/datasource/postgres/datasource.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/public/app/plugins/datasource/mssql/datasource.ts b/public/app/plugins/datasource/mssql/datasource.ts index 008b1b2f229..308cebaf6e6 100644 --- a/public/app/plugins/datasource/mssql/datasource.ts +++ b/public/app/plugins/datasource/mssql/datasource.ts @@ -61,6 +61,9 @@ export class MssqlDatasource extends SqlDatasource { } getDB(): DB { + if (this.db !== undefined) { + return this.db; + } return { init: () => Promise.resolve(true), datasets: () => this.fetchDatasets(), diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts index b8dc96573da..88d87cafabb 100644 --- a/public/app/plugins/datasource/postgres/datasource.ts +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -67,6 +67,9 @@ export class PostgresDatasource extends SqlDatasource { } getDB(): DB { + if (this.db !== undefined) { + return this.db; + } return { init: () => Promise.resolve(true), datasets: () => Promise.resolve([]), From 43436bd6f055be24db4d4f782aad1e4e73a1d1e7 Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Mon, 7 Nov 2022 16:06:40 +0000 Subject: [PATCH 093/926] Explore: Remove explore2Dashboard feature toggle (#58329) --- docs/sources/explore/_index.md | 6 ------ packages/grafana-data/src/types/featureToggles.gen.ts | 1 - pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.go | 4 ---- public/app/features/explore/ExploreToolbar.tsx | 2 +- 5 files changed, 1 insertion(+), 19 deletions(-) diff --git a/docs/sources/explore/_index.md b/docs/sources/explore/_index.md index 013e01db4c2..40e6f1303da 100644 --- a/docs/sources/explore/_index.md +++ b/docs/sources/explore/_index.md @@ -68,12 +68,6 @@ The Share shortened link capability allows you to create smaller and simpler URL ## Available feature toggles -### explore2Dashboard - -> **Note:** Available in Grafana 8.5.0 and later versions. - -Enabled by default, allows users to create panels in dashboards from within Explore. - ### exploreMixedDatasource Disabled by default, allows users in Explore to have different datasources for different queries. If compatible, results will be combined. diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f6c943a949d..be9f94d3b37 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -45,7 +45,6 @@ export interface FeatureToggles { dashboardsFromStorage?: boolean; export?: boolean; azureMonitorResourcePickerForMetrics?: boolean; - explore2Dashboard?: boolean; exploreMixedDatasource?: boolean; tracing?: boolean; commandPalette?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index f9220629234..814089f0bbf 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -166,13 +166,6 @@ var ( RequiresDevMode: true, FrontendOnly: true, }, - { - Name: "explore2Dashboard", - Description: "Experimental Explore to Dashboard workflow", - State: FeatureStateStable, - Expression: "true", // enabled by default - FrontendOnly: true, - }, { Name: "exploreMixedDatasource", Description: "Enable mixed datasource in Explore", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 44193087731..3ea262fdcf2 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -123,10 +123,6 @@ const ( // New UI for Azure Monitor Metrics Query FlagAzureMonitorResourcePickerForMetrics = "azureMonitorResourcePickerForMetrics" - // FlagExplore2Dashboard - // Experimental Explore to Dashboard workflow - FlagExplore2Dashboard = "explore2Dashboard" - // FlagExploreMixedDatasource // Enable mixed datasource in Explore FlagExploreMixedDatasource = "exploreMixedDatasource" diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 6703018efd6..b746c6f28f3 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -157,7 +157,7 @@ class UnConnectedExploreToolbar extends PureComponent { ), - config.featureToggles.explore2Dashboard && showExploreToDashboard && ( + showExploreToDashboard && ( From 480277f6129b61d4f23e85a31b0e46b052cbb498 Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Mon, 7 Nov 2022 16:25:49 +0000 Subject: [PATCH 094/926] CallResource: don't set Content-Type header if status is 204 (#50780) Grafana's HTTPServer ensures that the Content-Type header is always set in the response to a CallResource call, but when the status code is 204 No Content this shouldn't be done; the body should be empty and no Content-Type header should be set. We ran into this in the Grafana ML plugin where we were sending an empty response with status 204, but the frontend client saw that the content type was JSON and tried to parse it, resulting in an error that made it to the JS console. --- pkg/api/plugin_resource.go | 2 +- pkg/api/plugins_test.go | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/pkg/api/plugin_resource.go b/pkg/api/plugin_resource.go index bccbb034ea8..25e2089090e 100644 --- a/pkg/api/plugin_resource.go +++ b/pkg/api/plugin_resource.go @@ -193,7 +193,7 @@ func (hs *HTTPServer) flushStream(stream callResourceClientResponseStream, w htt // Expected that headers and status are only part of first stream if processedStreams == 0 && resp.Headers != nil { // Make sure a content type always is returned in response - if _, exists := resp.Headers["Content-Type"]; !exists { + if _, exists := resp.Headers["Content-Type"]; !exists && resp.Status != http.StatusNoContent { resp.Headers["Content-Type"] = []string{"application/json"} } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index a0871d07d92..ab9a5f977c2 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -331,10 +331,35 @@ func TestMakePluginResourceRequest(t *testing.T) { } } + require.Equal(t, resp.Header().Get("Content-Type"), "application/json") require.Equal(t, "sandbox", resp.Header().Get("Content-Security-Policy")) require.Empty(t, req.Header.Get(customHeader)) } +func TestMakePluginResourceRequestContentTypeEmpty(t *testing.T) { + pluginClient := &fakePluginClient{ + statusCode: http.StatusNoContent, + } + hs := HTTPServer{ + Cfg: setting.NewCfg(), + log: log.New(), + pluginClient: pluginClient, + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp := httptest.NewRecorder() + pCtx := backend.PluginContext{} + err := hs.makePluginResourceRequest(resp, req, pCtx) + require.NoError(t, err) + + for { + if resp.Flushed { + break + } + } + + require.Zero(t, resp.Header().Get("Content-Type")) +} + func callGetPluginAsset(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } @@ -366,6 +391,8 @@ type fakePluginClient struct { req *backend.CallResourceRequest backend.QueryDataHandlerFunc + + statusCode int } func (c *fakePluginClient) CallResource(_ context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { @@ -377,8 +404,13 @@ func (c *fakePluginClient) CallResource(_ context.Context, req *backend.CallReso return err } + statusCode := http.StatusOK + if c.statusCode != 0 { + statusCode = c.statusCode + } + return sender.Send(&backend.CallResourceResponse{ - Status: http.StatusOK, + Status: statusCode, Headers: make(map[string][]string), Body: bytes, }) From 8353f307aa2ed842c9045c1e420d51574019e905 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Mon, 7 Nov 2022 16:34:37 +0000 Subject: [PATCH 095/926] Alerting: Fix test fails in some environments (#58251) --- pkg/services/ngalert/models/image.go | 2 +- pkg/services/ngalert/models/image_test.go | 15 +++++++++++---- pkg/services/ngalert/models/time.go | 8 ++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 pkg/services/ngalert/models/time.go diff --git a/pkg/services/ngalert/models/image.go b/pkg/services/ngalert/models/image.go index 8c2d8cefd05..07c8578cdd9 100644 --- a/pkg/services/ngalert/models/image.go +++ b/pkg/services/ngalert/models/image.go @@ -27,7 +27,7 @@ func (i *Image) ExtendDuration(d time.Duration) { // HasExpired returns true if the image has expired. func (i *Image) HasExpired() bool { - return time.Now().After(i.ExpiresAt) + return timeNow().After(i.ExpiresAt) } // HasPath returns true if the image has a path on disk. diff --git a/pkg/services/ngalert/models/image_test.go b/pkg/services/ngalert/models/image_test.go index 5c8380cff04..7f9177fa8d6 100644 --- a/pkg/services/ngalert/models/image_test.go +++ b/pkg/services/ngalert/models/image_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/benbjohnson/clock" "github.com/stretchr/testify/assert" ) @@ -20,12 +21,18 @@ func TestImage_ExtendDuration(t *testing.T) { } func TestImage_HasExpired(t *testing.T) { + oldTimeNow := timeNow + timeNow = clock.NewMock().Now + t.Cleanup(func() { + timeNow = oldTimeNow + }) + var i Image - i.ExpiresAt = time.Now().Add(time.Minute) + i.ExpiresAt = timeNow().Add(time.Minute) assert.False(t, i.HasExpired()) - i.ExpiresAt = time.Now() - assert.True(t, i.HasExpired()) - i.ExpiresAt = time.Now().Add(-time.Minute) + i.ExpiresAt = timeNow() + assert.False(t, i.HasExpired()) + i.ExpiresAt = timeNow().Add(-time.Minute) assert.True(t, i.HasExpired()) } diff --git a/pkg/services/ngalert/models/time.go b/pkg/services/ngalert/models/time.go new file mode 100644 index 00000000000..4e7b1a91163 --- /dev/null +++ b/pkg/services/ngalert/models/time.go @@ -0,0 +1,8 @@ +package models + +import "time" + +var ( + // timeNow is an equivalent time.Now() that can be replaced in tests + timeNow = time.Now +) From 17cce385451c682265cef7a5c1ae94318cde45a8 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Mon, 7 Nov 2022 17:45:07 +0100 Subject: [PATCH 096/926] Loki Monaco Editor: implement extracted label keys (#57368) * feat(loki-monaco-editor): implement extracted label keys * Chore: add missing responseUtils tests * feat(loki-monaco-editor): suggest extracted labels * Chore: fix test case name * feat(loki-monaco-editor): dont suggest labels in logs query * Chore: remove console log * Chore: remove extracted keyword from suggested label * feat(loki-monaco-editor): do not suggest duplicated labels * refactor(loki-monaco-editor): pass query and offset to the completions resolver * Revert "refactor(loki-monaco-editor): pass query and offset to the completions resolver" This reverts commit d39464fd1a4624d5cd5420156dd2d1e2dad2eecf. * refactor(loki-monaco-editor): refactor label completions for grouping * Chore: remove obsolete function --- .../datasource/loki/LanguageProvider.test.ts | 11 ++-- .../datasource/loki/LanguageProvider.ts | 5 +- .../completions.test.ts | 32 ++++++------ .../monaco-completion-provider/completions.ts | 50 +++++++------------ .../datasource/loki/responseUtils.test.ts | 37 +++++++++++++- .../plugins/datasource/loki/responseUtils.ts | 11 ++++ 6 files changed, 89 insertions(+), 57 deletions(-) diff --git a/public/app/plugins/datasource/loki/LanguageProvider.test.ts b/public/app/plugins/datasource/loki/LanguageProvider.test.ts index c6dadce7cd0..a2429ffeb96 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.test.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.test.ts @@ -6,7 +6,7 @@ import { TypeaheadInput } from '@grafana/ui'; import LanguageProvider, { LokiHistoryItem } from './LanguageProvider'; import { LokiDatasource } from './datasource'; import { createLokiDatasource, createMetadataRequest } from './mocks'; -import { extractLogParserFromDataFrame } from './responseUtils'; +import { extractLogParserFromDataFrame, extractLabelKeysFromDataFrame } from './responseUtils'; import { LokiQueryType } from './types'; jest.mock('./responseUtils'); @@ -302,10 +302,13 @@ describe('Query imports', () => { describe('getParserAndLabelKeys()', () => { let datasource: LokiDatasource, languageProvider: LanguageProvider; - const extractLogParserFromDataFrameMock = extractLogParserFromDataFrame as jest.Mock; + const extractLogParserFromDataFrameMock = jest.mocked(extractLogParserFromDataFrame); + const extractedLabelKeys = ['extracted', 'label']; + beforeEach(() => { datasource = createLokiDatasource(); languageProvider = new LanguageProvider(datasource); + jest.mocked(extractLabelKeysFromDataFrame).mockReturnValue(extractedLabelKeys); }); it('identifies selectors with JSON parser data', async () => { @@ -313,7 +316,7 @@ describe('Query imports', () => { extractLogParserFromDataFrameMock.mockReturnValueOnce({ hasLogfmt: false, hasJSON: true }); expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ - extractedLabelKeys: [], + extractedLabelKeys, hasJSON: true, hasLogfmt: false, }); @@ -324,7 +327,7 @@ describe('Query imports', () => { extractLogParserFromDataFrameMock.mockReturnValueOnce({ hasLogfmt: true, hasJSON: false }); expect(await languageProvider.getParserAndLabelKeys('{place="luna"}')).toEqual({ - extractedLabelKeys: [], + extractedLabelKeys, hasJSON: false, hasLogfmt: true, }); diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts index b95692c8e4c..ee98b9690fe 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.ts @@ -12,7 +12,7 @@ import { } from 'app/plugins/datasource/prometheus/language_utils'; import { LokiDatasource } from './datasource'; -import { extractLogParserFromDataFrame } from './responseUtils'; +import { extractLabelKeysFromDataFrame, extractLogParserFromDataFrame } from './responseUtils'; import syntax, { FUNCTIONS, PIPE_PARSERS, PIPE_OPERATORS } from './syntax'; import { LokiQuery, LokiQueryType } from './types'; @@ -474,7 +474,6 @@ export default class LokiLanguageProvider extends LanguageProvider { const { hasLogfmt, hasJSON } = extractLogParserFromDataFrame(series[0]); - // TODO: figure out extractedLabelKeys - return { extractedLabelKeys: [], hasJSON, hasLogfmt }; + return { extractedLabelKeys: extractLabelKeysFromDataFrame(series[0]), hasJSON, hasLogfmt }; } } diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts index 4fde82901b2..caab25519ad 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -29,7 +29,8 @@ const history = [ const labelNames = ['place', 'source']; const labelValues = ['moon', 'luna', 'server\\1']; -const extractedLabelKeys = ['extracted', 'label']; +// Source is duplicated to test handling duplicated labels +const extractedLabelKeys = ['extracted', 'place', 'source']; const otherLabels: Label[] = [ { name: 'place', @@ -98,12 +99,17 @@ const afterSelectorCompletions = [ }, { insertText: '| unwrap extracted', - label: 'unwrap extracted (detected)', + label: 'unwrap extracted', type: 'LINE_FILTER', }, { - insertText: '| unwrap label', - label: 'unwrap label (detected)', + insertText: '| unwrap place', + label: 'unwrap place', + type: 'LINE_FILTER', + }, + { + insertText: '| unwrap source', + label: 'unwrap source', type: 'LINE_FILTER', }, { @@ -215,6 +221,12 @@ describe('getCompletions', () => { const completions = await getCompletions(situation, completionProvider); expect(completions).toEqual([ + { + insertText: 'extracted', + label: 'extracted', + triggerOnInsert: false, + type: 'LABEL_NAME', + }, { insertText: 'place', label: 'place', @@ -227,18 +239,6 @@ describe('getCompletions', () => { triggerOnInsert: false, type: 'LABEL_NAME', }, - { - insertText: 'extracted', - label: 'extracted (parsed)', - triggerOnInsert: false, - type: 'LABEL_NAME', - }, - { - insertText: 'label', - label: 'label (parsed)', - triggerOnInsert: false, - type: 'LABEL_NAME', - }, ]); }); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts index 77e36d7e95e..65b83d8915c 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -109,48 +109,32 @@ async function getAllHistoryCompletions(dataProvider: CompletionDataProvider): P })); } -async function getLabelNamesForCompletions( - suffix: string, - triggerOnInsert: boolean, - addExtractedLabels: boolean, - otherLabels: Label[], - dataProvider: CompletionDataProvider -): Promise { - const labelNames = await dataProvider.getLabelNames(otherLabels); - const result: Completion[] = labelNames.map((text) => ({ - type: 'LABEL_NAME', - label: text, - insertText: `${text}${suffix}`, - triggerOnInsert, - })); - - if (addExtractedLabels) { - const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(otherLabels); - extractedLabelKeys.forEach((key) => { - result.push({ - type: 'LABEL_NAME', - label: `${key} (parsed)`, - insertText: `${key}${suffix}`, - triggerOnInsert, - }); - }); - } - - return result; -} - async function getLabelNamesForSelectorCompletions( otherLabels: Label[], dataProvider: CompletionDataProvider ): Promise { - return getLabelNamesForCompletions('=', true, false, otherLabels, dataProvider); + const labelNames = await dataProvider.getLabelNames(otherLabels); + + return labelNames.map((label) => ({ + type: 'LABEL_NAME', + label, + insertText: `${label}=`, + triggerOnInsert: true, + })); } async function getInGroupingCompletions( otherLabels: Label[], dataProvider: CompletionDataProvider ): Promise { - return getLabelNamesForCompletions('', false, true, otherLabels, dataProvider); + const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(otherLabels); + + return extractedLabelKeys.map((label) => ({ + type: 'LABEL_NAME', + label, + insertText: label, + triggerOnInsert: false, + })); } const PARSERS = ['json', 'logfmt', 'pattern', 'regexp', 'unpack']; @@ -204,7 +188,7 @@ async function getAfterSelectorCompletions( extractedLabelKeys.forEach((key) => { completions.push({ type: 'LINE_FILTER', - label: `unwrap ${key} (detected)`, + label: `unwrap ${key}`, insertText: `${prefix}unwrap ${key}`, }); }); diff --git a/public/app/plugins/datasource/loki/responseUtils.test.ts b/public/app/plugins/datasource/loki/responseUtils.test.ts index c65663d7ea7..aeb1e4f1814 100644 --- a/public/app/plugins/datasource/loki/responseUtils.test.ts +++ b/public/app/plugins/datasource/loki/responseUtils.test.ts @@ -2,7 +2,13 @@ import { cloneDeep } from 'lodash'; import { ArrayVector, DataFrame, FieldType } from '@grafana/data'; -import { dataFrameHasLevelLabel, dataFrameHasLokiError, extractLevelLikeLabelFromDataFrame } from './responseUtils'; +import { + dataFrameHasLevelLabel, + dataFrameHasLokiError, + extractLevelLikeLabelFromDataFrame, + extractLogParserFromDataFrame, + extractLabelKeysFromDataFrame, +} from './responseUtils'; const frame: DataFrame = { length: 1, @@ -70,3 +76,32 @@ describe('extractLevelLikeLabelFromDataFrame', () => { expect(extractLevelLikeLabelFromDataFrame(input)).toBe(null); }); }); + +describe('extractLogParserFromDataFrame', () => { + it('returns false by default', () => { + const input = cloneDeep(frame); + expect(extractLogParserFromDataFrame(input)).toEqual({ hasJSON: false, hasLogfmt: false }); + }); + it('identifies JSON', () => { + const input = cloneDeep(frame); + input.fields[2].values = new ArrayVector(['{"a":"b"}']); + expect(extractLogParserFromDataFrame(input)).toEqual({ hasJSON: true, hasLogfmt: false }); + }); + it('identifies logfmt', () => { + const input = cloneDeep(frame); + input.fields[2].values = new ArrayVector(['a=b']); + expect(extractLogParserFromDataFrame(input)).toEqual({ hasJSON: false, hasLogfmt: true }); + }); +}); + +describe('extractLabelKeysFromDataFrame', () => { + it('returns empty by default', () => { + const input = cloneDeep(frame); + input.fields[1].values = new ArrayVector([]); + expect(extractLabelKeysFromDataFrame(input)).toEqual([]); + }); + it('extracts label keys', () => { + const input = cloneDeep(frame); + expect(extractLabelKeysFromDataFrame(input)).toEqual(['level']); + }); +}); diff --git a/public/app/plugins/datasource/loki/responseUtils.ts b/public/app/plugins/datasource/loki/responseUtils.ts index e5b88a6b499..ef9c5902644 100644 --- a/public/app/plugins/datasource/loki/responseUtils.ts +++ b/public/app/plugins/datasource/loki/responseUtils.ts @@ -36,6 +36,17 @@ export function extractLogParserFromDataFrame(frame: DataFrame): { hasLogfmt: bo return { hasLogfmt, hasJSON }; } +export function extractLabelKeysFromDataFrame(frame: DataFrame): string[] { + const labelsArray: Array<{ [key: string]: string }> | undefined = + frame?.fields?.find((field) => field.name === 'labels')?.values.toArray() ?? []; + + if (!labelsArray?.length) { + return []; + } + + return Object.keys(labelsArray[0]); +} + export function extractHasErrorLabelFromDataFrame(frame: DataFrame): boolean { const labelField = frame.fields.find((field) => field.name === 'labels' && field.type === FieldType.other); if (labelField == null) { From 0a8fdc45506ef437f5ed422276282bc0e58c7b0e Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Mon, 7 Nov 2022 17:51:49 +0100 Subject: [PATCH 097/926] GoogleCloudMonitoring: Remove unused code (#58347) --- .../cloud-monitoring/components/Fields.tsx | 62 +------------------ .../cloud-monitoring/components/index.ts | 2 +- 2 files changed, 2 insertions(+), 62 deletions(-) diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx index 9f2495666e0..0f772486752 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx @@ -1,10 +1,7 @@ -import { css } from '@emotion/css'; import React, { FC } from 'react'; import { SelectableValue } from '@grafana/data'; -import { HorizontalGroup, InlineField, InlineLabel, PopoverContent, Select } from '@grafana/ui'; - -import { INNER_LABEL_WIDTH, LABEL_WIDTH } from '../constants'; +import { InlineField, Select } from '@grafana/ui'; interface VariableQueryFieldProps { onChange: (value: string) => void; @@ -33,60 +30,3 @@ export const VariableQueryField: FC = ({ ); }; - -export interface Props { - children: React.ReactNode; - tooltip?: PopoverContent; - label?: React.ReactNode; - className?: string; - noFillEnd?: boolean; - labelWidth?: number; - fillComponent?: React.ReactNode; - htmlFor?: string; -} - -export const QueryEditorRow: FC = ({ - children, - label, - tooltip, - fillComponent, - noFillEnd = false, - labelWidth = LABEL_WIDTH, - htmlFor, - ...rest -}) => { - return ( -
- {label && ( - - {label} - - )} -
- - {children} - -
-
- {noFillEnd ||
{fillComponent}
} -
-
- ); -}; - -export const QueryEditorField: FC = ({ children, label, tooltip, labelWidth = INNER_LABEL_WIDTH, ...rest }) => { - return ( - <> - {label && ( - - {label} - - )} - {children} - - ); -}; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/index.ts b/public/app/plugins/datasource/cloud-monitoring/components/index.ts index a4e3e6d33fb..1651a77898c 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/index.ts +++ b/public/app/plugins/datasource/cloud-monitoring/components/index.ts @@ -10,7 +10,7 @@ export { Aggregation } from './Aggregation'; export { MetricQueryEditor } from './MetricQueryEditor'; export { SLOQueryEditor } from './SLOQueryEditor'; export { MQLQueryEditor } from './MQLQueryEditor'; -export { VariableQueryField, QueryEditorRow, QueryEditorField } from './Fields'; +export { VariableQueryField } from './Fields'; export { VisualMetricQueryEditor } from './VisualMetricQueryEditor'; export { PeriodSelect } from './PeriodSelect'; export { Preprocessor } from './Preprocessor'; From 2027f4702cc421889d4e3a4163db82a1fb302394 Mon Sep 17 00:00:00 2001 From: Gareth Dawson Date: Mon, 7 Nov 2022 17:01:06 +0000 Subject: [PATCH 098/926] Loki: Add case insensitive line contains operation (#58177) * add line-contains-case-insensitive operation definition * add loki operation id for line-contains-case-insensitive * make query case-insensitive when using line filter * remove console log from operationUtils.ts * add line-does-not-contain-case-insensitive operation definition * add loki operation id for line-does-not-contain-case-insensitive * make query case insensitive when using line-does-not-contain-case-insensitive * update title and min-width for line-contains-case-insensitive * add caseInsensitive optional parameter * toggle case insensitive on operations * remove console log * update operation names * add test coverage * update to implement suggestions * add suggestion --- .../loki/querybuilder/operationUtils.test.ts | 36 ++++++++++++++- .../loki/querybuilder/operationUtils.ts | 5 ++- .../loki/querybuilder/operations.ts | 44 +++++++++++++++++++ .../datasource/loki/querybuilder/types.ts | 2 + 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts index 82f5c17d86d..ba4e6b8c569 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.test.ts @@ -1,4 +1,6 @@ -import { createRangeOperation, createRangeOperationWithGrouping } from './operationUtils'; +import { QueryBuilderOperationDef } from '../../prometheus/querybuilder/shared/types'; + +import { createRangeOperation, createRangeOperationWithGrouping, getLineFilterRenderer } from './operationUtils'; import { LokiVisualQueryOperationCategory } from './types'; describe('createRangeOperation', () => { @@ -122,3 +124,35 @@ describe('createRangeOperationWithGrouping', () => { expect(query).toBe('avg_over_time({job="grafana"} [[$__interval]]) without (source)'); }); }); + +describe('getLineFilterRenderer', () => { + const MOCK_MODEL = { + id: '__line_contains', + params: ['error'], + }; + const MOCK_MODEL_INSENSITIVE = { + id: '__line_contains_case_insensitive', + params: ['ERrOR'], + }; + + const MOCK_DEF = undefined as unknown as QueryBuilderOperationDef; + + const MOCK_INNER_EXPR = '{job="grafana"}'; + + it('getLineFilterRenderer returns a function', () => { + const lineFilterRenderer = getLineFilterRenderer('!~'); + expect(typeof lineFilterRenderer).toBe('function'); + }); + + it('lineFilterRenderer returns the correct query for line contains', () => { + const lineFilterRenderer = getLineFilterRenderer('!~'); + expect(lineFilterRenderer(MOCK_MODEL, MOCK_DEF, MOCK_INNER_EXPR)).toBe('{job="grafana"} !~ `error`'); + }); + + it('lineFilterRenderer returns the correct query for line contains case insensitive', () => { + const lineFilterRenderer = getLineFilterRenderer('!~', true); + expect(lineFilterRenderer(MOCK_MODEL_INSENSITIVE, MOCK_DEF, MOCK_INNER_EXPR)).toBe( + '{job="grafana"} !~ `(?i)ERrOR`' + ); + }); +}); diff --git a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts index cac96b0eaca..b9808dbb41a 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operationUtils.ts @@ -255,8 +255,11 @@ export function addNestedQueryHandler(def: QueryBuilderOperationDef, query: Loki }; } -export function getLineFilterRenderer(operation: string) { +export function getLineFilterRenderer(operation: string, caseInsensitive?: boolean) { return function lineFilterRenderer(model: QueryBuilderOperation, def: QueryBuilderOperationDef, innerExpr: string) { + if (caseInsensitive) { + return `${innerExpr} ${operation} \`(?i)${model.params[0]}\``; + } return `${innerExpr} ${operation} \`${model.params[0]}\``; }; } diff --git a/public/app/plugins/datasource/loki/querybuilder/operations.ts b/public/app/plugins/datasource/loki/querybuilder/operations.ts index 8df076b4d2e..72609bef1fd 100644 --- a/public/app/plugins/datasource/loki/querybuilder/operations.ts +++ b/public/app/plugins/datasource/loki/querybuilder/operations.ts @@ -260,6 +260,50 @@ Example: \`\`error_level=\`level\` \`\` addOperationHandler: addLokiOperation, explainHandler: (op) => `Return log lines that does not contain string \`${op.params[0]}\`.`, }, + { + id: LokiOperationId.LineContainsCaseInsensitive, + name: 'Line contains case insensitive', + params: [ + { + name: 'String', + type: 'string', + hideName: true, + placeholder: 'Text to find', + description: 'Find log lines that contains this text', + minWidth: 33, + runQueryOnEnter: true, + }, + ], + defaultParams: [''], + alternativesKey: 'line filter', + category: LokiVisualQueryOperationCategory.LineFilters, + orderRank: LokiOperationOrder.LineFilters, + renderer: getLineFilterRenderer('|~', true), + addOperationHandler: addLokiOperation, + explainHandler: (op) => `Return log lines that match regex \`(?i)${op.params[0]}\`.`, + }, + { + id: LokiOperationId.LineContainsNotCaseInsensitive, + name: 'Line does not contain case insensitive', + params: [ + { + name: 'String', + type: 'string', + hideName: true, + placeholder: 'Text to exclude', + description: 'Find log lines that does not contain this text', + minWidth: 40, + runQueryOnEnter: true, + }, + ], + defaultParams: [''], + alternativesKey: 'line filter', + category: LokiVisualQueryOperationCategory.LineFilters, + orderRank: LokiOperationOrder.LineFilters, + renderer: getLineFilterRenderer('!~', true), + addOperationHandler: addLokiOperation, + explainHandler: (op) => `Return log lines that does not match regex \`(?i)${op.params[0]}\`.`, + }, { id: LokiOperationId.LineMatchesRegex, name: 'Line contains regex match', diff --git a/public/app/plugins/datasource/loki/querybuilder/types.ts b/public/app/plugins/datasource/loki/querybuilder/types.ts index 12a4a27e580..e4f30107b71 100644 --- a/public/app/plugins/datasource/loki/querybuilder/types.ts +++ b/public/app/plugins/datasource/loki/querybuilder/types.ts @@ -66,6 +66,8 @@ export enum LokiOperationId { BottomK = 'bottomk', LineContains = '__line_contains', LineContainsNot = '__line_contains_not', + LineContainsCaseInsensitive = '__line_contains_case_insensitive', + LineContainsNotCaseInsensitive = '__line_contains_not_case_insensitive', LineMatchesRegex = '__line_matches_regex', LineMatchesRegexNot = '__line_matches_regex_not', LineFilterIpMatches = '__line_filter_ip_matches', From 76947b10e2cae93daef38a88c3c7db1b6a197f85 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Mon, 7 Nov 2022 18:12:17 +0000 Subject: [PATCH 099/926] Auth: conflicting users validation improvements (#58136) * WIP * add: better validation of conflict file * add: better description of validation and ingest command * add: check for at least one user to delete * add: example in terraform to setup for conflicts * Update pkg/cmd/grafana-cli/commands/conflict_user_command.go Co-authored-by: Ieva * Add: print of conflict block for error - adds conflict block to error output for validation of the file to easier diagnose in the file * fix: formatting of errors * fix: info strings improvements * add: default 0 to blocks to check for users * fixed: tests * test integration * fix strings fmt * set store in resolver Co-authored-by: Ieva --- pkg/cmd/grafana-cli/commands/commands.go | 2 +- .../conflict_example_users.tf | 64 ++++++++++++++ .../commands/conflict_user_command.go | 87 +++++++++++++++---- .../commands/conflict_user_command_test.go | 68 ++++++++++++--- 4 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 84d06624898..44e4aab888d 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -219,7 +219,7 @@ var adminCommands = []*cli.Command{ }, { Name: "ingest-file", - Usage: "ingests the conflict users file", + Usage: "ingests the conflict users file. > Note: This is irreversible it will change the state of the database.", Action: runIngestConflictUsersFile(), }, }, diff --git a/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf b/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf new file mode 100644 index 00000000000..8203ea8704c --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/conflict-examples/conflict_example_users.tf @@ -0,0 +1,64 @@ +terraform { + required_providers { + grafana = { + source = "grafana/grafana" + } + } +} + +// Configure the Grafana Provider +provider "grafana" { + url = "http://localhost:3000/" + auth = "admin:admin" +} + +// login conflict +// Creating the grafana-login +resource "grafana_user" "grafana-login" { + email = "grafana_login@grafana.com" + login = "GRAFANA_LOGIN@grafana.com" + password = "grafana_login@grafana.com" + is_admin = false +} + +// Creating the grafana-login +resource "grafana_user" "grafana-login-2" { + email = "grafana_login_2@grafana.com" + login = "grafana_login@grafana.com" + password = "grafana_login@grafana.com" + is_admin = false +} + +// email conflict +// Creating the grafana-email +resource "grafana_user" "grafana-email" { + email = "grafana_email@grafana.com" + login = "grafana_email@grafana.com" + password = "grafana_email@grafana.com" + is_admin = false +} + +// Creating the grafana-email +resource "grafana_user" "grafana-email-2" { + email = "GRAFANA_EMAIL@grafana.com" + login = "grafana_email_2@grafana.com" + password = "grafana_email@grafana.com" + is_admin = false +} + +// email and login conflict +// Creating the grafana-user +resource "grafana_user" "grafana-user" { + email = "grafana_user@grafana.com" + login = "grafana_user@grafana.com" + password = "grafana_user@grafana.com" + is_admin = false +} + +// Creating the grafana-user +resource "grafana_user" "grafana-user-2" { + email = "GRAFANA_USER@grafana.com" + login = "GRAFANA_USER@grafana.com" + password = "grafana_user@grafana.com" + is_admin = false +} diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index ed46179e85f..a1464e72e0b 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -9,6 +9,7 @@ import ( "regexp" "strconv" "strings" + "unicode" "github.com/fatih/color" "github.com/urfave/cli/v2" @@ -54,7 +55,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) } - resolver := ConflictResolver{Users: conflicts} + resolver := ConflictResolver{Users: conflicts, Store: s} resolver.BuildConflictBlocks(conflicts, f) return &resolver, nil } @@ -127,17 +128,20 @@ func runValidateConflictUsersFile() func(context *cli.Context) error { // read in the file to ingest arg := cmd.Args().First() if arg == "" { - return errors.New("please specify a absolute path to file to read from") + return fmt.Errorf("please specify a absolute path to file to read from") } b, err := os.ReadFile(filepath.Clean(arg)) if err != nil { - return fmt.Errorf("could not read file with error %e", err) + logger.Error(color.RedString("validation failed with an error")) + return fmt.Errorf("could not read file with error %s", err) } validErr := getValidConflictUsers(r, b) if validErr != nil { - return fmt.Errorf("could not validate file with error %s", err) + logger.Error(color.RedString("validation failed with an error")) + return fmt.Errorf("could not validate file with error:\n%s", validErr) } - logger.Info("File validation complete without errors.\n\n File can be used with ingesting command `ingest-file`.\n\n") + logger.Info(color.GreenString("File validation complete.\n")) + logger.Info("File can be used with the `ingest-file` command.\n\n") return nil } } @@ -161,7 +165,7 @@ func runIngestConflictUsersFile() func(context *cli.Context) error { } validErr := getValidConflictUsers(r, b) if validErr != nil { - return fmt.Errorf("could not validate file with error %s", validErr) + return fmt.Errorf("could not validate file with error:\n%s", validErr) } // should we rebuild blocks here? // kind of a weird thing maybe? @@ -169,7 +173,7 @@ func runIngestConflictUsersFile() func(context *cli.Context) error { return fmt.Errorf("no users") } r.showChanges() - if !confirm("\n\nWe encourage users to create a db backup before running this command. \n Proceed with operation?") { + if !confirm("\n\nWe encourage users to create a db backup before running this command. \n Proceed with operation") { return fmt.Errorf("user cancelled") } err = r.MergeConflictingUsers(context.Context) @@ -230,7 +234,6 @@ func getValidConflictUsers(r *ConflictResolver, b []byte) error { previouslySeenLogins[strings.ToLower(u.Login)] = true } } - // tested in https://regex101.com/r/una3zC/1 diffPattern := `^[+-]` // compiling since in a loop @@ -238,23 +241,50 @@ func getValidConflictUsers(r *ConflictResolver, b []byte) error { if err != nil { return fmt.Errorf("unable to compile regex %s: %w", diffPattern, err) } - for _, row := range strings.Split(string(b), "\n") { + counterKeepUsersForBlock := map[string]int{} + counterDeleteUsersForBlock := map[string]int{} + currentBlock := "" + for rowNumber, row := range strings.Split(string(b), "\n") { + // end of file if row == "" { - // end of file break } // if the row starts with a #, it is a comment if row[0] == '#' { - // comment - continue - } - entryRow := matchingExpression.Match([]byte(row)) - if !entryRow { - // block row - // conflict: hej continue } + entryRow := matchingExpression.Match([]byte(row)) + // not an entry row -> is a conflict block row + if !entryRow { + // check for malformed row + // rows should be of the form + // conflict: + // or + // + id: + // - id: + if (row[0] != '-') && (row[0] != '+') && (row[0] != 'c') { + return fmt.Errorf("invalid start character (expected '+,-') found %c for row number %d", row[0], rowNumber+1) + } + + // is a conflict block row + // conflict: hej + currentBlock = row + continue + } + // need to track how many keep users we have for a block + if _, ok := counterKeepUsersForBlock[currentBlock]; !ok { + counterKeepUsersForBlock[currentBlock] = 0 + } + if _, ok := counterDeleteUsersForBlock[currentBlock]; !ok { + counterDeleteUsersForBlock[currentBlock] = 0 + } + if row[0] == '+' { + counterKeepUsersForBlock[currentBlock] += 1 + } + if row[0] == '-' { + counterDeleteUsersForBlock[currentBlock] += 1 + } newUser := &ConflictingUser{} err := newUser.Marshal(row) if err != nil { @@ -269,6 +299,18 @@ func getValidConflictUsers(r *ConflictResolver, b []byte) error { // valid entry newConflicts = append(newConflicts, *newUser) } + for block, count := range counterKeepUsersForBlock { + // check if we only have one addition for each block + if count != 1 { + return fmt.Errorf("invalid number of users to keep, expected 1, got %d for block: %s", count, block) + } + } + for block, count := range counterDeleteUsersForBlock { + // check if we have at least one deletion for each block + if count < 1 { + return fmt.Errorf("invalid number of users to delete, should be at least 1, got %d for block %s", count, block) + } + } r.ValidUsers = newConflicts r.BuildConflictBlocks(newConflicts, fmt.Sprintf) return nil @@ -378,7 +420,14 @@ func (r *ConflictResolver) showChanges() { } b.WriteString("Keep the following user.\n") b.WriteString(fmt.Sprintf("%s\n", block)) - b.WriteString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, mainUser.Email, mainUser.Login)) + b.WriteString(color.GreenString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, mainUser.Email, mainUser.Login))) + for _, r := range fmt.Sprintf("%s%s", mainUser.Email, mainUser.Login) { + if unicode.IsUpper(r) { + b.WriteString("Will be change to:\n") + b.WriteString(color.GreenString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, strings.ToLower(mainUser.Email), strings.ToLower(mainUser.Login)))) + break + } + } b.WriteString("\n\n") b.WriteString("The following user(s) will be deleted.\n") for _, user := range users { @@ -386,7 +435,7 @@ func (r *ConflictResolver) showChanges() { continue } // mergeable users - b.WriteString(fmt.Sprintf("id: %s, email: %s, login: %s\n", user.ID, user.Email, user.Login)) + b.WriteString(color.RedString(fmt.Sprintf("id: %s, email: %s, login: %s\n", user.ID, user.Email, user.Login))) } b.WriteString("\n\n") } diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index 5912b6c4cf4..5dabb855245 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -577,7 +577,7 @@ func TestRunValidateConflictUserFile(t *testing.T) { }) } -func TestMergeUser(t *testing.T) { +func TestIntegrationMergeUser(t *testing.T) { t.Run("should be able to merge user", func(t *testing.T) { // Restore after destructive operation sqlStore := db.InitTestDB(t) @@ -632,17 +632,15 @@ func TestMergeUser(t *testing.T) { }) } -func TestMergeUserFromNewFileInput(t *testing.T) { +func TestIntegrationMergeUserFromNewFileInput(t *testing.T) { t.Run("should be able to merge users after choosing a different user to keep", func(t *testing.T) { - // Restore after destructive operation - sqlStore := db.InitTestDB(t) - type testBuildConflictBlock struct { - desc string - users []user.User - fileString string - expectedBlocks []string - expectedIdsInBlocks map[string][]string + desc string + users []user.User + fileString string + expectedValidationErr error + expectedBlocks []string + expectedIdsInBlocks map[string][]string } testOrgID := 1 m := make(map[string][]string) @@ -690,8 +688,52 @@ conflict: test2 expectedBlocks: []string{"conflict: test", "conflict: test2"}, expectedIdsInBlocks: m, }, + { + desc: "should give error for having wrong number of users to keep", + users: []user.User{ + { + Email: "TEST", + Login: "TEST", + OrgID: int64(testOrgID), + }, + { + Email: "test", + Login: "test", + OrgID: int64(testOrgID), + }, + }, + fileString: `conflict: test ++ id: 1, email: test, login: test, last_seen_at: 2012-09-19T08:31:20Z, auth_module:, conflict_email: true, conflict_login: true ++ id: 2, email: TEST, login: TEST, last_seen_at: 2012-09-19T08:31:29Z, auth_module:, conflict_email: true, conflict_login: true +`, + expectedValidationErr: fmt.Errorf("invalid number of users to keep, expected 1, got 2 for block: conflict: test"), + expectedBlocks: []string{"conflict: test"}, + }, + { + desc: "should give error for having wrong character for user", + users: []user.User{ + { + Email: "TEST", + Login: "TEST", + OrgID: int64(testOrgID), + }, + { + Email: "test", + Login: "test", + OrgID: int64(testOrgID), + }, + }, + fileString: `conflict: test ++ id: 1, email: test, login: test, last_seen_at: 2012-09-19T08:31:20Z, auth_module:, conflict_email: true, conflict_login: true +% id: 2, email: TEST, login: TEST, last_seen_at: 2012-09-19T08:31:29Z, auth_module:, conflict_email: true, conflict_login: true +`, + expectedValidationErr: fmt.Errorf("invalid start character (expected '+,-') found %% for row number 3"), + expectedBlocks: []string{"conflict: test"}, + }, } for _, tc := range testCases { + // Restore after destructive operation + sqlStore := db.InitTestDB(t) if sqlStore.GetDialect().DriverName() != ignoredDatabase { for _, u := range tc.users { cmd := user.CreateUserCommand{ @@ -716,7 +758,11 @@ conflict: test2 b := tc.fileString require.NoError(t, err) validErr := getValidConflictUsers(&r, []byte(b)) - require.NoError(t, validErr) + if tc.expectedValidationErr != nil { + require.Equal(t, tc.expectedValidationErr, validErr) + } else { + require.NoError(t, validErr) + } // test starts here err = r.MergeConflictingUsers(context.Background()) From 93c1fbbe3f3996e2e0d39d39acb235c802d37ed4 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Mon, 7 Nov 2022 21:20:00 +0300 Subject: [PATCH 100/926] Remove data comparison tool and feature flag (#58196) --- .../src/types/featureToggles.gen.ts | 1 - pkg/services/featuremgmt/registry.go | 5 -- pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/tsdb/prometheus/prometheus.go | 87 +------------------ 4 files changed, 3 insertions(+), 94 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index be9f94d3b37..c4334575773 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -53,7 +53,6 @@ export interface FeatureToggles { datasourceQueryMultiStatus?: boolean; traceToMetrics?: boolean; prometheusStreamingJSONParser?: boolean; - prometheusStreamingJSONParserTest?: boolean; newDBLibrary?: boolean; validateDashboardsOnSave?: boolean; autoMigrateGraphPanels?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 814089f0bbf..1ba42512802 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -212,11 +212,6 @@ var ( Description: "Enable streaming JSON parser for Prometheus datasource", State: FeatureStateBeta, }, - { - Name: "prometheusStreamingJSONParserTest", - Description: "Run both old and streaming requests and log differences", - State: FeatureStateBeta, - }, { Name: "newDBLibrary", Description: "Use jmoiron/sqlx rather than xorm for a few backend services", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3ea262fdcf2..432aac5a880 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -155,10 +155,6 @@ const ( // Enable streaming JSON parser for Prometheus datasource FlagPrometheusStreamingJSONParser = "prometheusStreamingJSONParser" - // FlagPrometheusStreamingJSONParserTest - // Run both old and streaming requests and log differences - FlagPrometheusStreamingJSONParserTest = "prometheusStreamingJSONParserTest" - // FlagNewDBLibrary // Use jmoiron/sqlx rather than xorm for a few backend services FlagNewDBLibrary = "newDBLibrary" diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 43b12d31ddb..4f9d592dcae 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -2,31 +2,25 @@ package prometheus import ( "context" - "encoding/json" "errors" "fmt" - "reflect" "strings" - "sync" "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" - "github.com/grafana/grafana/pkg/tsdb/prometheus/client" - "github.com/patrickmn/go-cache" - apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" - "github.com/yudai/gojsondiff" - "github.com/yudai/gojsondiff/formatter" - "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/prometheus/buffered" + "github.com/grafana/grafana/pkg/tsdb/prometheus/client" "github.com/grafana/grafana/pkg/tsdb/prometheus/querydata" "github.com/grafana/grafana/pkg/tsdb/prometheus/resource" + "github.com/patrickmn/go-cache" + apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" ) var plog = log.New("tsdb.prometheus") @@ -103,36 +97,6 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return i.queryData.Execute(ctx, req) } - // To test the new client implementation this can be run and we do 2 requests and compare. - if s.features.IsEnabled(featuremgmt.FlagPrometheusStreamingJSONParserTest) { - var wg sync.WaitGroup - var streamData *backend.QueryDataResponse - var streamError error - - var data *backend.QueryDataResponse - var err error - - plog.FromContext(ctx).Debug("PrometheusStreamingJSONParserTest", "req", req) - - wg.Add(1) - go func() { - defer wg.Done() - streamData, streamError = i.queryData.Execute(ctx, req) - }() - - wg.Add(1) - go func() { - defer wg.Done() - data, err = i.buffered.ExecuteTimeSeriesQuery(ctx, req) - }() - - wg.Wait() - - // Report can take a while and we don't really need to wait for it. - go reportDiff(data, err, streamData, streamError) - return data, err - } - return i.buffered.ExecuteTimeSeriesQuery(ctx, req) } @@ -187,48 +151,3 @@ func ConvertAPIError(err error) error { } return err } - -func reportDiff(data *backend.QueryDataResponse, err error, streamData *backend.QueryDataResponse, streamError error) { - if err == nil && streamError != nil { - plog.Debug("PrometheusStreamingJSONParserTest error in streaming client", "err", streamError) - } - - if err != nil && streamError == nil { - plog.Debug("PrometheusStreamingJSONParserTest error in buffer but not streaming", "err", err) - } - - if !reflect.DeepEqual(data, streamData) { - plog.Debug("PrometheusStreamingJSONParserTest buffer and streaming data are different") - dataJson, jsonErr := json.MarshalIndent(data, "", "\t") - if jsonErr != nil { - plog.Debug("PrometheusStreamingJSONParserTest error marshaling data", "jsonErr", jsonErr) - } - streamingJson, jsonErr := json.MarshalIndent(streamData, "", "\t") - if jsonErr != nil { - plog.Debug("PrometheusStreamingJSONParserTest error marshaling streaming data", "jsonErr", jsonErr) - } - differ := gojsondiff.New() - d, diffErr := differ.Compare(dataJson, streamingJson) - if diffErr != nil { - plog.Debug("PrometheusStreamingJSONParserTest diff error", "err", diffErr) - } - config := formatter.AsciiFormatterConfig{ - ShowArrayIndex: true, - Coloring: true, - } - - var aJson map[string]interface{} - unmarshallErr := json.Unmarshal(dataJson, &aJson) - if unmarshallErr != nil { - plog.Debug("PrometheusStreamingJSONParserTest unmarshall error", "err", unmarshallErr) - } - formatter := formatter.NewAsciiFormatter(aJson, config) - diffString, diffErr := formatter.Format(d) - if diffErr != nil { - plog.Debug("PrometheusStreamingJSONParserTest diff format error", "err", diffErr) - } - fmt.Println(diffString) - } else { - plog.Debug("PrometheusStreamingJSONParserTest responses are the same") - } -} From 0315f6317e2653da203c9ac0f21eceb9aa9e29de Mon Sep 17 00:00:00 2001 From: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Date: Mon, 7 Nov 2022 13:40:09 -0600 Subject: [PATCH 101/926] Docs: corrects outer join example (#58348) * corrects outer join example * Update docs/sources/panels-visualizations/query-transform-data/transform-data/index.md * adds query tables back in --- .../query-transform-data/transform-data/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md index c38203a71d2..39095841390 100644 --- a/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md +++ b/docs/sources/panels-visualizations/query-transform-data/transform-data/index.md @@ -359,11 +359,9 @@ The result after applying the inner join transformation looks like the following #### Outer join -An outer join includes all data from an inner join and rows where values do not match in every input. +An outer join includes all data from an inner join and rows where values do not match in every input. While the inner join joins Query A and Query B on the time field, the outer join includes all rows that don’t match on the time field. -Use this transformation to combine the results from multiple queries (combining on a passed join field or the first time column) into one result, and drop rows where a successful join cannot occur - performing an inner join. - -In the following example, two queries return table data. It is visualized as two tables before applying the inner join transformation. +In the following example, two queries return table data. It is visualized as two tables before applying the outer join transformation. Query A: @@ -381,10 +379,12 @@ Query B: | 2020-07-07 11:24:20 | server 2 | 5 | | 2020-07-07 11:04:20 | server 3 | 10 | -The result after applying the inner join transformation looks like the following: +The result after applying the outer join transformation looks like the following: | Time | Job | Uptime | Server | Errors | | ------------------- | ------- | --------- | -------- | ------ | +| 2020-07-07 11:04:20 | | | server 3 | 10 | +| 2020-07-07 11:14:20 | postgre | 345001233 | | | | 2020-07-07 11:34:20 | node | 25260122 | server 1 | 15 | | 2020-07-07 11:24:20 | postgre | 123001233 | server 2 | 5 | From faa0fda6eb846752983447c2590c68d68c6cb173 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Tue, 8 Nov 2022 08:35:05 +0100 Subject: [PATCH 102/926] Prometheus: Upgrades http client to v1.13.1 (#58363) --- go.mod | 3 +-- go.sum | 10 ++-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 7746c751186..b3c2668a26e 100644 --- a/go.mod +++ b/go.mod @@ -84,7 +84,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 github.com/prometheus/alertmanager v0.24.1-0.20221003101219-ae510d09c048 - github.com/prometheus/client_golang v1.13.0 + github.com/prometheus/client_golang v1.13.1 github.com/prometheus/client_model v0.2.0 github.com/prometheus/common v0.37.0 github.com/prometheus/prometheus v1.8.2-0.20211011171444-354d8d2ecfac @@ -152,7 +152,6 @@ require ( github.com/deepmap/oapi-codegen v1.10.1 github.com/dennwc/varint v1.0.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/edsrzf/mmap-go v1.0.0 // indirect github.com/emicklei/proto v1.10.0 // indirect diff --git a/go.sum b/go.sum index 389a506fe56..7c1c6710824 100644 --- a/go.sum +++ b/go.sum @@ -736,8 +736,6 @@ github.com/digitalocean/godo v1.65.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2x github.com/digitalocean/godo v1.80.0 h1:ZULJ/fWDM97YtO7Fa+K6hzJLd7+smCu4N+0n+B/xtj4= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 h1:Izz0+t1Z5nI16/II7vuEo/nHjodOg0p7+OiDpjX5t1E= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= github.com/dlmiddlecote/sqlstats v1.0.2/go.mod h1:0CWaIh/Th+z2aI6Q9Jpfg/o21zmGxWhbByHgQSCUQvY= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= @@ -761,9 +759,6 @@ github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/dop251/goja v0.0.0-20210804101310-32956a348b49 h1:CtSi0QlA2Hy+nOh8JAZoiEBLW5pliAiKJ3l1Iq1472I= -github.com/dop251/goja v0.0.0-20210804101310-32956a348b49/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= -github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/drone-runners/drone-runner-docker v1.8.2 h1:F7+39FSyzEUqLXYMvTdTGBhCS79ODDIhw3DQeF5GYT8= github.com/drone-runners/drone-runner-docker v1.8.2/go.mod h1:JR3pZeVZKKpkbTajiq0YtAx9WutkODdVKZGNR83kEwE= github.com/drone/drone-cli v1.6.1 h1:Beh0opEGR5XYezOyOmiqWzTMBGHkGDrh2tIG1cY/5GY= @@ -1954,8 +1949,6 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/ohler55/ojg v1.12.9 h1:HIHORjvA/i2IyDGgf9zzkFZc0yhEZIi3Tte+m+XBzTs= -github.com/ohler55/ojg v1.12.9/go.mod h1:LBbIVRAgoFbYBXQhRhuEpaJIqq+goSO63/FQ+nyJU88= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= @@ -2108,8 +2101,9 @@ github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66Id github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU= github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= +github.com/prometheus/client_golang v1.13.1 h1:3gMjIY2+/hzmqhtUC/aQNYldJA6DtH3CgQvwS+02K1c= +github.com/prometheus/client_golang v1.13.1/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= github.com/prometheus/client_model v0.0.0-20170216185247-6f3806018612/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= From 326ea86a579ed927b1999bba5f2c0a35e26506d9 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue, 8 Nov 2022 10:25:34 +0200 Subject: [PATCH 103/926] Chore: Refactor quota service (#57586) * Chore: refactore quota service * Apply suggestions from code review --- pkg/api/admin_users.go | 2 +- pkg/api/api.go | 22 +- pkg/api/common_test.go | 18 +- pkg/api/dashboard.go | 2 +- pkg/api/dashboard_test.go | 26 +- pkg/api/folder_test.go | 2 +- pkg/api/metrics_test.go | 8 +- pkg/api/org_test.go | 15 +- pkg/api/org_users_test.go | 60 ++- pkg/api/plugin_dashboards_test.go | 2 +- pkg/api/pluginproxy/ds_proxy_test.go | 118 +++-- pkg/api/plugins_test.go | 2 +- pkg/api/quota.go | 88 ++-- pkg/api/quota_test.go | 31 +- pkg/api/user_test.go | 4 +- pkg/cmd/grafana-cli/runner/wire.go | 2 +- pkg/middleware/quota.go | 6 +- pkg/middleware/quota_test.go | 71 +-- pkg/models/quotas.go | 91 ---- pkg/models/user_token.go | 4 - pkg/server/wire.go | 2 +- .../resourcepermissions/service_test.go | 4 +- .../annotationsimpl/xorm_store_test.go | 9 +- pkg/services/apikey/apikeyimpl/apikey.go | 54 +- pkg/services/apikey/apikeyimpl/sqlx_store.go | 33 ++ pkg/services/apikey/apikeyimpl/store.go | 3 + pkg/services/apikey/apikeyimpl/xorm_store.go | 46 ++ pkg/services/apikey/model.go | 6 + pkg/services/auth/auth_token.go | 51 +- pkg/services/auth/auth_token_test.go | 13 +- pkg/services/auth/model.go | 6 + pkg/services/dashboardimport/api/api.go | 11 +- pkg/services/dashboardimport/api/api_test.go | 5 +- pkg/services/dashboards/dashboard.go | 2 + pkg/services/dashboards/database/acl_test.go | 6 +- pkg/services/dashboards/database/database.go | 86 +++- .../database/database_folder_test.go | 26 +- .../database/database_provisioning_test.go | 5 +- .../dashboards/database/database_test.go | 20 +- pkg/services/dashboards/models.go | 6 + .../dashboard_service_integration_test.go | 75 +-- pkg/services/dashboards/store_mock.go | 5 + pkg/services/datasources/models.go | 6 + .../datasources/service/datasource.go | 43 +- .../datasources/service/datasource_test.go | 45 +- pkg/services/datasources/service/store.go | 48 ++ .../folder/folderimpl/sqlstore_test.go | 4 +- .../guardian/accesscontrol_guardian_test.go | 8 +- .../libraryelements/libraryelements_test.go | 15 +- .../librarypanels/librarypanels_test.go | 15 +- .../login/loginservice/loginservice.go | 18 +- .../login/loginservice/loginservice_test.go | 8 +- pkg/services/ngalert/api/api.go | 25 + pkg/services/ngalert/api/api_ruler.go | 2 +- pkg/services/ngalert/api/persist.go | 2 + pkg/services/ngalert/models/alert_rule.go | 6 + pkg/services/ngalert/ngalert.go | 42 ++ pkg/services/ngalert/provisioning/persist.go | 2 +- .../provisioning/quota_checker_mock.go | 29 +- pkg/services/ngalert/store/alert_rule.go | 24 + pkg/services/ngalert/tests/fakes/rules.go | 4 + pkg/services/ngalert/tests/util.go | 7 +- pkg/services/org/model.go | 6 + pkg/services/org/orgimpl/org.go | 51 +- pkg/services/org/orgimpl/org_test.go | 5 + pkg/services/org/orgimpl/store.go | 70 +++ .../publicdashboards/api/query_test.go | 4 +- .../database/database_test.go | 48 +- .../publicdashboards/service/query_test.go | 13 +- .../publicdashboards/service/service_test.go | 30 +- pkg/services/query/query_test.go | 5 +- pkg/services/quota/context.go | 42 ++ pkg/services/quota/model.go | 210 +++++++- pkg/services/quota/quota.go | 23 +- pkg/services/quota/quotaimpl/quota.go | 450 +++++++++++------ pkg/services/quota/quotaimpl/quota_test.go | 469 +++++++++++++++++- pkg/services/quota/quotaimpl/store.go | 115 ++++- pkg/services/quota/quotaimpl/store_test.go | 4 +- pkg/services/quota/quotatest/fake.go | 38 +- .../kvstore/migrations/datasource_mig_test.go | 6 +- pkg/services/serviceaccounts/api/api_test.go | 26 +- .../serviceaccounts/api/token_test.go | 9 +- .../serviceaccounts/database/database_test.go | 8 +- pkg/services/serviceaccounts/tests/common.go | 7 +- pkg/services/sqlstore/mockstore/mockstore.go | 28 -- pkg/services/sqlstore/quota.go | 315 ------------ pkg/services/sqlstore/quota_test.go | 301 ----------- pkg/services/sqlstore/store.go | 7 - pkg/services/store/service.go | 68 ++- pkg/services/store/service_test.go | 4 +- pkg/services/user/model.go | 5 + pkg/services/user/userimpl/store.go | 19 + pkg/services/user/userimpl/user.go | 50 +- pkg/services/user/userimpl/user_test.go | 4 + pkg/setting/setting.go | 10 +- pkg/setting/setting_quota.go | 48 +- .../api/alerting/api_alertmanager_test.go | 29 +- pkg/tests/api/alerting/testing.go | 55 ++ pkg/tsdb/legacydata/service/service_test.go | 9 +- 99 files changed, 2595 insertions(+), 1397 deletions(-) delete mode 100644 pkg/models/quotas.go create mode 100644 pkg/services/quota/context.go delete mode 100644 pkg/services/sqlstore/quota.go delete mode 100644 pkg/services/sqlstore/quota_test.go diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 7daf81be95c..e164cc49d63 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -245,7 +245,7 @@ func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { return nil }) g.Go(func() error { - if err := hs.QuotaService.DeleteByUser(ctx, cmd.UserID); err != nil { + if err := hs.QuotaService.DeleteQuotaForUser(ctx, cmd.UserID); err != nil { return err } return nil diff --git a/pkg/api/api.go b/pkg/api/api.go index 5692a904322..0a76dfab147 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -36,12 +36,16 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "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/correlations" "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/org" publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -69,8 +73,8 @@ func (hs *HTTPServer) registerRoutes() { // not logged in views r.Get("/logout", hs.Logout) - r.Post("/login", quota("session"), routing.Wrap(hs.LoginPost)) - r.Get("/login/:name", quota("session"), hs.OAuthLogin) + r.Post("/login", quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPost)) + r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin) r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) @@ -173,7 +177,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/verify", hs.Index) r.Get("/signup", hs.Index) r.Get("/api/user/signup/options", routing.Wrap(GetSignUpOptions)) - r.Post("/api/user/signup", quota("user"), routing.Wrap(hs.SignUp)) + r.Post("/api/user/signup", quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.SignUp)) r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2)) // invited @@ -192,7 +196,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index) // api renew session based on cookie - r.Get("/api/login/ping", quota("session"), routing.Wrap(hs.LoginAPIPing)) + r.Get("/api/login/ping", quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginAPIPing)) // expose plugin file system assets r.Get("/public/plugins/:pluginId/*", hs.getPluginAssets) @@ -298,13 +302,13 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Put("/address", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrgAddress)) orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsersWithPaging)) - orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota("user"), routing.Wrap(hs.AddOrgUserToCurrentOrg)) + orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.AddOrgUserToCurrentOrg)) orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUserForCurrentOrg)) orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUserForCurrentOrg)) // invites orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.GetPendingOrgInvites)) - orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota("user"), routing.Wrap(hs.AddOrgInvite)) + orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota(user.QuotaTargetSrv), quota(user.QuotaTargetSrv), routing.Wrap(hs.AddOrgInvite)) orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.RevokeInvite)) // prefs @@ -331,7 +335,7 @@ func (hs *HTTPServer) registerRoutes() { }) // create new org - apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota("org"), routing.Wrap(hs.CreateOrg)) + apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota(org.QuotaTargetSrv), routing.Wrap(hs.CreateOrg)) // search all orgs apiRoute.Get("/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.SearchOrgs)) @@ -358,7 +362,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { apikeyIDScope := ac.Scope("apikeys", "id", ac.Parameter(":id")) keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead)), routing.Wrap(hs.GetAPIKeys)) - keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey)) + keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota(string(apikey.QuotaTargetSrv)), routing.Wrap(hs.AddAPIKey)) keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey)) }) @@ -373,7 +377,7 @@ func (hs *HTTPServer) registerRoutes() { uidScope := datasources.ScopeProvider.GetResourceScopeUID(ac.Parameter(":uid")) nameScope := datasources.ScopeProvider.GetResourceScopeName(ac.Parameter(":name")) datasourceRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSources)) - datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota("data_source"), routing.Wrap(hs.AddDataSource)) + datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota(string(datasources.QuotaTargetSrv)), routing.Wrap(hs.AddDataSource)) datasourceRoute.Put("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, idScope)), routing.Wrap(hs.UpdateDataSourceByID)) datasourceRoute.Put("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(hs.UpdateDataSourceByUID)) datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, idScope)), routing.Wrap(hs.DeleteDataSourceById)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 8ca9c240b8e..0ecb6a38188 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -45,7 +45,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/preference/preftest" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/search" @@ -249,15 +248,13 @@ func (s *fakeRenderService) Init() error { } func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []accesscontrol.Permission) (*scenarioContext, *HTTPServer) { - cfg.Quota.Enabled = false - - store := db.InitTestDB(t) + store := sqlstore.InitTestDB(t) hs := &HTTPServer{ Cfg: cfg, Live: newTestLive(t, store), License: &licensing.OSSLicensingService{}, Features: featuremgmt.WithFeatures(), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), RouteRegister: routing.NewRouteRegister(), AccessControl: accesscontrolmock.New().WithPermissions(permissions), searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()), @@ -376,7 +373,9 @@ func setupHTTPServerWithCfgDb( routeRegister := routing.NewRouteRegister() teamService := teamimpl.ProvideService(db, cfg) cfg.IsFeatureToggleEnabled = features.IsEnabled - dashboardsStore := dashboardsstore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, cfg)) + quotaService := quotatest.New(false, nil) + dashboardsStore, err := dashboardsstore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, cfg), quotaService) + require.NoError(t, err) var acmock *accesscontrolmock.Mock var ac accesscontrol.AccessControl @@ -402,7 +401,8 @@ func setupHTTPServerWithCfgDb( acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), featuremgmt.WithFeatures()) require.NoError(t, err) ac = acimpl.ProvideAccessControl(cfg) - userSvc = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService()) + userSvc, err = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) } teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, license, acService, teamService, userSvc) require.NoError(t, err) @@ -412,7 +412,7 @@ func setupHTTPServerWithCfgDb( Cfg: cfg, Features: features, Live: newTestLive(t, db), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotaService, RouteRegister: routeRegister, SQLStore: store, License: &licensing.OSSLicensingService{}, @@ -497,7 +497,7 @@ func SetupAPITestServer(t *testing.T, opts ...APITestServerOption) *webtest.Serv RouteRegister: routing.NewRouteRegister(), License: &licensing.OSSLicensingService{}, Features: featuremgmt.WithFeatures(), - QuotaService: quotatest.NewQuotaServiceFake(), + QuotaService: quotatest.New(false, nil), searchUsersService: &searchusers.OSSService{}, } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 2f3f335d389..f8d47c8866e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -407,7 +407,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa dash := cmd.GetDashboardModel() newDashboard := dash.Id == 0 if newDashboard { - limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") + limitReached, err := hs.QuotaService.QuotaReached(c, dashboards.QuotaTargetSrv) if err != nil { return response.Error(500, "failed to get quota", err) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index b4fc7c52e3c..6c506ee49a1 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -39,7 +39,7 @@ import ( pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/preference/preftest" "github.com/grafana/grafana/pkg/services/provisioning" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -150,6 +150,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, Coremodels: registry.NewBase(nil), + QuotaService: quotatest.New(false, nil), } setUp := func() { @@ -990,9 +991,12 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr provisioningService = provisioning.NewProvisioningServiceMock(context.Background()) } + var err error if dashboardStore == nil { sql := db.InitTestDB(t) - dashboardStore = database.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err = database.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) + require.NoError(t, err) } libraryPanelsService := mockLibraryPanelService{} @@ -1031,7 +1035,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr require.Equal(sc.t, 200, sc.resp.Code) dash := dtos.DashboardFullWithMeta{} - err := json.NewDecoder(sc.resp.Body).Decode(&dash) + err = json.NewDecoder(sc.resp.Body).Decode(&dash) require.NoError(sc.t, err) return dash @@ -1077,12 +1081,10 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { cfg := setting.NewCfg() hs := HTTPServer{ - Cfg: cfg, - ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), - Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{ - Cfg: cfg, - }, + Cfg: cfg, + ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), + Live: newTestLive(t, db.InitTestDB(t)), + QuotaService: quotatest.New(false, nil), pluginStore: &plugins.FakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, @@ -1116,7 +1118,7 @@ func postValidateScenario(t *testing.T, desc string, url string, routePattern st Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, @@ -1152,7 +1154,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, @@ -1190,7 +1192,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, DashboardService: mock, diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index bc7ace258a9..1d759717a91 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -146,7 +146,7 @@ func TestHTTPServer_FolderMetadata(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.folderService = folderService hs.AccessControl = acmock.New() - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Should attach access control metadata to multiple folders", func(t *testing.T) { diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index 8f7961a9daf..3992a5cac1d 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -94,12 +94,12 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) { serverFeatureEnabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagDatasourceQueryMultiStatus, true) - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) serverFeatureDisabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagDatasourceQueryMultiStatus, false) - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Status code is 400 when data source response has an error and feature toggle is disabled", func(t *testing.T) { @@ -142,7 +142,7 @@ func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) { ) httpServer := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Status code is 500 and a secrets plugin error is returned if there is a problem getting secrets from the remote plugin", func(t *testing.T) { @@ -294,7 +294,7 @@ func TestDataSourceQueryError(t *testing.T) { pluginClient.ProvideService(r, &config.Cfg{}), &fakeOAuthTokenService{}, ) - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) req := srv.NewPostRequest("/api/ds/query", strings.NewReader(tc.request)) webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleViewer}) diff --git a/pkg/api/org_test.go b/pkg/api/org_test.go index e97d330e312..49ed8000aae 100644 --- a/pkg/api/org_test.go +++ b/pkg/api/org_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" @@ -104,7 +105,8 @@ func TestAPIEndpoint_PutCurrentOrg_LegacyAccessControl(t *testing.T) { }) setInitCtxSignedInOrgAdmin(sc.initCtx) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) t.Run("Admin can update current org", func(t *testing.T) { response := callAPI(sc.server, http.MethodPut, putCurrentOrgURL, input, t) assert.Equal(t, http.StatusOK, response.Code) @@ -118,7 +120,8 @@ func TestAPIEndpoint_PutCurrentOrg_AccessControl(t *testing.T) { _, err := sc.db.CreateOrgWithMember("TestOrg", sc.initCtx.UserID) require.NoError(t, err) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) input := strings.NewReader(testUpdateOrgNameForm) t.Run("AccessControl allows updating current org with correct permissions", func(t *testing.T) { @@ -436,7 +439,9 @@ func TestAPIEndpoint_PutOrg_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + var err error + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) // Create two orgs, to update another one than the logged in one setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) @@ -456,7 +461,9 @@ func TestAPIEndpoint_PutOrg_LegacyAccessControl(t *testing.T) { func TestAPIEndpoint_PutOrg_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + var err error + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) // Create two orgs, to update another one than the logged in one setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 71a6b00db7d..3dbe71300ae 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -389,11 +390,13 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotatest.New(false, nil)) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) @@ -403,7 +406,7 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { require.Equal(t, tc.expectedCode, response.Code) var userList []*models.OrgUserDTO - err := json.NewDecoder(response.Body).Decode(&userList) + err = json.NewDecoder(response.Body).Decode(&userList) require.NoError(t, err) if tc.expectedMetadata != nil { @@ -493,11 +496,14 @@ func TestGetOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + quotaService := quotatest.New(false, nil) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) + require.NoError(t, err) }) setInitCtxSignedInUser(sc.initCtx, tc.user) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -598,10 +604,11 @@ func TestPostOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -716,11 +723,12 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { + var err error sc := setupHTTPServer(t, true, func(hs *HTTPServer) { hs.tempUserService = tempuserimpl.ProvideService(hs.SQLStore) - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), - ) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) }) setInitCtxSignedInViewer(sc.initCtx) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -835,11 +843,14 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + quotaService := quotatest.New(false, nil) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) @@ -962,11 +973,14 @@ func TestDeleteOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + quotaService := quotatest.New(false, nil) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) diff --git a/pkg/api/plugin_dashboards_test.go b/pkg/api/plugin_dashboards_test.go index e98116f96f4..6ad7abfcc95 100644 --- a/pkg/api/plugin_dashboards_test.go +++ b/pkg/api/plugin_dashboards_test.go @@ -41,7 +41,7 @@ func TestGetPluginDashboards(t *testing.T) { s := SetupAPITestServer(t, func(hs *HTTPServer) { hs.pluginDashboardService = pluginDashboardService - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Not signed in should return 404 Not Found", func(t *testing.T) { diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index e31a16c07c1..af7ec30e5ac 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -32,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" @@ -138,7 +139,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -151,7 +154,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -163,7 +168,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -174,7 +181,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -188,7 +197,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -197,7 +208,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -207,7 +220,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = org.RoleAdmin - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -298,7 +313,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -314,7 +331,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -331,7 +350,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -355,7 +376,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -382,7 +405,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -408,7 +433,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -438,7 +465,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -463,7 +492,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -514,7 +545,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -651,7 +684,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -671,7 +706,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -687,7 +724,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -711,7 +750,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -738,7 +779,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -764,7 +807,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -790,8 +835,11 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) - _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) + quotaService := quotatest.New(false, nil) + var err error + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) + _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) } @@ -812,8 +860,10 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) - _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) + _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) } @@ -856,7 +906,9 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) if tc.err == nil { require.NoError(t, err) @@ -884,7 +936,9 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -1001,7 +1055,9 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets tracer := tracing.InitializeTracerForTest() var routes []*plugins.Route - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -1045,7 +1101,9 @@ func Test_PathCheck(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(&datasources.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index ab9a5f977c2..0afe045c0a8 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -60,7 +60,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled, } hs.pluginInstaller = inst - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run(testName("Install", tc), func(t *testing.T) { diff --git a/pkg/api/quota.go b/pkg/api/quota.go index 9d3fa2a5c0b..dd0ee9f538d 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -6,10 +6,22 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) +// swagger:route GET /org/quotas getCurrentOrg getCurrentOrgQuota +// +// Fetch Organization quota. +// +// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope). +// +// Responses: +// 200: getQuotaResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Response { return hs.getOrgQuotasHelper(c, c.OrgID) } @@ -29,22 +41,17 @@ func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Respons func (hs *HTTPServer) GetOrgQuotas(c *models.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "orgId is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) } return hs.getOrgQuotasHelper(c, orgId) } func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) response.Response { - if !hs.Cfg.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) + q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.OrgScope, orgID) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get quota", err) } - query := models.GetOrgQuotasQuery{OrgId: orgID} - - if err := hs.SQLStore.GetOrgQuotas(c.Req.Context(), &query); err != nil { - return response.Error(500, "Failed to get org quotas", err) - } - - return response.JSON(http.StatusOK, query.Result) + return response.JSON(http.StatusOK, q) } // swagger:route PUT /orgs/{org_id}/quotas/{quota_target} orgs updateOrgQuota @@ -63,26 +70,19 @@ func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) resp // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { - cmd := models.UpdateOrgQuotaCmd{} + cmd := quota.UpdateQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) + return response.Err(quota.ErrBadRequest.Errorf("bad request data: %w", err)) } - if !hs.Cfg.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) - } - cmd.OrgId, err = strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) + cmd.OrgID, err = strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "orgId is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) } cmd.Target = web.Params(c.Req)[":target"] - if _, ok := hs.Cfg.Quota.Org.ToMap()[cmd.Target]; !ok { - return response.Error(404, "Invalid quota target", nil) - } - - if err := hs.SQLStore.UpdateOrgQuota(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to update org quotas", err) + if err := hs.QuotaService.Update(c.Req.Context(), &cmd); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update org quotas", err) } return response.Success("Organization quota updated") } @@ -114,22 +114,17 @@ func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { - if !setting.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) - } - id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "id is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) } - query := models.GetUserQuotasQuery{UserId: id} - - if err := hs.SQLStore.GetUserQuotas(c.Req.Context(), &query); err != nil { - return response.Error(500, "Failed to get org quotas", err) + q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.UserScope, id) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to get org quotas", err) } - return response.JSON(http.StatusOK, query.Result) + return response.JSON(http.StatusOK, q) } // swagger:route PUT /admin/users/{user_id}/quotas/{quota_target} admin_users updateUserQuota @@ -148,26 +143,19 @@ func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { - cmd := models.UpdateUserQuotaCmd{} + cmd := quota.UpdateQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) + return response.Err(quota.ErrBadRequest.Errorf("bad request data: %w", err)) } - if !setting.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) - } - cmd.UserId, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) + cmd.UserID, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "id is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) } cmd.Target = web.Params(c.Req)[":target"] - if _, ok := setting.Quota.User.ToMap()[cmd.Target]; !ok { - return response.Error(404, "Invalid quota target", nil) - } - - if err := hs.SQLStore.UpdateUserQuota(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to update org quotas", err) + if err := hs.QuotaService.Update(c.Req.Context(), &cmd); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update org quotas", err) } return response.Success("Organization quota updated") } @@ -176,7 +164,7 @@ func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { type UpdateUserQuotaParams struct { // in:body // required:true - Body models.UpdateUserQuotaCmd `json:"body"` + Body quota.UpdateQuotaCmd `json:"body"` // in:path // required:true QuotaTarget string `json:"quota_target"` @@ -203,7 +191,7 @@ type GetOrgQuotaParams struct { type UpdateOrgQuotaParam struct { // in:body // required:true - Body models.UpdateOrgQuotaCmd `json:"body"` + Body quota.UpdateQuotaCmd `json:"body"` // in:path // required:true QuotaTarget string `json:"quota_target"` @@ -215,5 +203,5 @@ type UpdateOrgQuotaParam struct { // swagger:response getQuotaResponse type GetQuotaResponseResponse struct { // in:body - Body []*models.UserQuotaDTO `json:"body"` + Body []*quota.QuotaDTO `json:"body"` } diff --git a/pkg/api/quota_test.go b/pkg/api/quota_test.go index 51a6806a35f..36e128f9124 100644 --- a/pkg/api/quota_test.go +++ b/pkg/api/quota_test.go @@ -32,17 +32,13 @@ var testOrgQuota = setting.OrgQuota{ func setupDBAndSettingsForAccessControlQuotaTests(t *testing.T, sc accessControlScenarioContext) { t.Helper() - sc.hs.Cfg.Quota.Enabled = true - sc.hs.Cfg.Quota.Org = &testOrgQuota - // Required while sqlstore quota.go relies on setting global variables - setting.Quota = sc.hs.Cfg.Quota - // Create two orgs with the context user setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) } func TestAPIEndpoint_GetCurrentOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() + cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -62,7 +58,9 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { - sc := setupHTTPServer(t, true) + cfg := setting.NewCfg() + cfg.Quota.Enabled = true + sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) setupDBAndSettingsForAccessControlQuotaTests(t, sc) @@ -86,6 +84,7 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { func TestAPIEndpoint_GetOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() + cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -105,7 +104,9 @@ func TestAPIEndpoint_GetOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { - sc := setupHTTPServer(t, true) + cfg := setting.NewCfg() + cfg.Quota.Enabled = true + sc := setupHTTPServerWithCfg(t, true, cfg) setupDBAndSettingsForAccessControlQuotaTests(t, sc) t.Run("AccessControl allows viewing another org quotas with correct permissions", func(t *testing.T) { @@ -130,6 +131,7 @@ func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { func TestAPIEndpoint_PutOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() + cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -151,7 +153,20 @@ func TestAPIEndpoint_PutOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_PutOrgQuotas_AccessControl(t *testing.T) { - sc := setupHTTPServer(t, true) + cfg := setting.NewCfg() + cfg.Quota = setting.QuotaSettings{ + Enabled: true, + Global: setting.GlobalQuota{ + Org: 5, + }, + Org: setting.OrgQuota{ + User: 5, + }, + User: setting.UserQuota{ + Org: 5, + }, + } + sc := setupHTTPServerWithCfg(t, true, cfg) setupDBAndSettingsForAccessControlQuotaTests(t, sc) input := strings.NewReader(testUpdateOrgQuotaCmd) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 0998a71687c..5108aebabc9 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -20,6 +20,7 @@ import ( acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/secrets/database" @@ -68,7 +69,8 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } user, err := sqlStore.CreateUser(context.Background(), createUserCmd) require.Nil(t, err) - hs.userService = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil) + hs.userService, err = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) sc.handlerFunc = hs.GetUserByID diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 49819799069..8d0d85fda7a 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -254,7 +254,7 @@ var wireSet = wire.NewSet( wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, auth.ProvideActiveAuthTokenService, - wire.Bind(new(models.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/middleware/quota.go b/pkg/middleware/quota.go index 57533436ebe..7a0689ff11d 100644 --- a/pkg/middleware/quota.go +++ b/pkg/middleware/quota.go @@ -14,15 +14,15 @@ func Quota(quotaService quota.Service) func(string) web.Handler { panic("quotaService is nil") } //https://open.spotify.com/track/7bZSoBEAEEUsGEuLOf94Jm?si=T1Tdju5qRSmmR0zph_6RBw fuuuuunky - return func(target string) web.Handler { + return func(targetSrv string) web.Handler { return func(c *models.ReqContext) { - limitReached, err := quotaService.QuotaReached(c, target) + limitReached, err := quotaService.QuotaReached(c, quota.TargetSrv(targetSrv)) if err != nil { c.JsonApiErr(500, "Failed to get quota", err) return } if limitReached { - c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", target), nil) + c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", targetSrv), nil) return } } diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 3f0aacd89ab..446b7842933 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -30,8 +30,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.User = 4 }) middlewareScenario(t, "and global session quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -41,8 +39,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.Session = 10 }) middlewareScenario(t, "and global session quota reached", func(t *testing.T, sc *scenarioContext) { @@ -52,13 +48,10 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.Session = 1 }) }) t.Run("with user logged in", func(t *testing.T) { - const quotaUsed = 4 setUp := func(sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{UserID: 12} @@ -79,8 +72,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.DataSource = quotaUsed }) middlewareScenario(t, "user Org quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -93,8 +84,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.User.Org = quotaUsed + 1 }) middlewareScenario(t, "user Org quota reached", func(t *testing.T, sc *scenarioContext) { @@ -106,8 +95,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.User.Org = quotaUsed }) middlewareScenario(t, "org dashboard quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -119,8 +106,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.Dashboard = quotaUsed + 1 }) middlewareScenario(t, "org dashboard quota reached", func(t *testing.T, sc *scenarioContext) { @@ -132,8 +117,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.Dashboard = quotaUsed }) middlewareScenario(t, "org dashboard quota reached, but quotas disabled", func(t *testing.T, sc *scenarioContext) { @@ -145,9 +128,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.Dashboard = quotaUsed - cfg.Quota.Enabled = false }) middlewareScenario(t, "org alert quota reached and unified alerting is enabled", func(t *testing.T, sc *scenarioContext) { @@ -162,7 +142,6 @@ func TestMiddlewareQuota(t *testing.T) { cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true - cfg.Quota.Org.AlertRule = quotaUsed }) middlewareScenario(t, "org alert quota not reached and unified alerting is enabled", func(t *testing.T, sc *scenarioContext) { @@ -177,7 +156,6 @@ func TestMiddlewareQuota(t *testing.T) { cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true - cfg.Quota.Org.AlertRule = quotaUsed + 1 }) middlewareScenario(t, "org alert quota reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { @@ -190,8 +168,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.AlertRule = quotaUsed }) middlewareScenario(t, "org alert quota not reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { @@ -203,58 +179,15 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.AlertRule = quotaUsed + 1 }) }) } func getQuotaHandler(reached bool, target string) web.Handler { - qs := &mockQuotaService{ - reached: reached, - } + qs := quotatest.New(reached, nil) return Quota(qs)(target) } func configure(cfg *setting.Cfg) { cfg.AnonymousEnabled = false - cfg.Quota = setting.QuotaSettings{ - Enabled: true, - Org: &setting.OrgQuota{ - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - AlertRule: 5, - }, - User: &setting.UserQuota{ - Org: 5, - }, - Global: &setting.GlobalQuota{ - Org: 5, - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - Session: 5, - AlertRule: 5, - }, - } -} - -type mockQuotaService struct { - reached bool - err error -} - -func (m *mockQuotaService) QuotaReached(c *models.ReqContext, target string) (bool, error) { - return m.reached, m.err -} - -func (m *mockQuotaService) CheckQuotaReached(c context.Context, target string, params *quota.ScopeParameters) (bool, error) { - return m.reached, m.err -} - -func (m *mockQuotaService) DeleteByUser(c context.Context, userID int64) error { - return m.err } diff --git a/pkg/models/quotas.go b/pkg/models/quotas.go deleted file mode 100644 index 26a63a92423..00000000000 --- a/pkg/models/quotas.go +++ /dev/null @@ -1,91 +0,0 @@ -package models - -import ( - "errors" - "time" -) - -var ErrInvalidQuotaTarget = errors.New("invalid quota target") - -type Quota struct { - Id int64 - OrgId int64 - UserId int64 - Target string - Limit int64 - Created time.Time - Updated time.Time -} - -type QuotaScope struct { - Name string - Target string - DefaultLimit int64 -} - -type OrgQuotaDTO struct { - OrgId int64 `json:"org_id"` - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` -} - -type UserQuotaDTO struct { - UserId int64 `json:"user_id"` - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` -} - -type GlobalQuotaDTO struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` -} - -type GetOrgQuotaByTargetQuery struct { - Target string - OrgId int64 - Default int64 - UnifiedAlertingEnabled bool - Result *OrgQuotaDTO -} - -type GetOrgQuotasQuery struct { - OrgId int64 - UnifiedAlertingEnabled bool - Result []*OrgQuotaDTO -} - -type GetUserQuotaByTargetQuery struct { - Target string - UserId int64 - Default int64 - UnifiedAlertingEnabled bool - Result *UserQuotaDTO -} - -type GetUserQuotasQuery struct { - UserId int64 - UnifiedAlertingEnabled bool - Result []*UserQuotaDTO -} - -type GetGlobalQuotaByTargetQuery struct { - Target string - Default int64 - UnifiedAlertingEnabled bool - Result *GlobalQuotaDTO -} - -type UpdateOrgQuotaCmd struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - OrgId int64 `json:"-"` -} - -type UpdateUserQuotaCmd struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - UserId int64 `json:"-"` -} diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 6ce74c004f3..6c92a40d86b 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -76,10 +76,6 @@ type UserTokenService interface { GetUserRevokedTokens(ctx context.Context, userId int64) ([]*UserToken, error) } -type ActiveTokenService interface { - ActiveTokenCount(ctx context.Context) (int64, error) -} - type UserTokenBackgroundService interface { registry.BackgroundService } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 1123c997893..90fbdd0a9d8 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -272,7 +272,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, auth.ProvideActiveAuthTokenService, - wire.Bind(new(models.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index 7c033d2f4a8..c1352b89f9b 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -225,7 +226,8 @@ func setupTestEnvironment(t *testing.T, permissions []accesscontrol.Permission, sql := db.InitTestDB(t) cfg := setting.NewCfg() teamSvc := teamimpl.ProvideService(sql, cfg) - userSvc := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil) + userSvc, err := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil, quotatest.New(false, nil)) + require.NoError(t, err) license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() mock := accesscontrolmock.New().WithPermissions(permissions) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index e7615243fdc..44239e50f95 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -56,7 +57,9 @@ func TestIntegrationAnnotations(t *testing.T) { assert.NoError(t, err) }) - dashboardStore := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) + require.NoError(t, err) testDashboard1 := models.SaveDashboardCommand{ UserId: 1, @@ -453,7 +456,9 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { var maximumTagsLength int64 = 60 repo := xormRepositoryImpl{db: sql, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(sql, sql.Cfg), maximumTagsLength: maximumTagsLength} - dashboardStore := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) + require.NoError(t, err) testDashboard1 := models.SaveDashboardCommand{ UserId: 1, diff --git a/pkg/services/apikey/apikeyimpl/apikey.go b/pkg/services/apikey/apikeyimpl/apikey.go index 4b2af715707..2a09d26319f 100644 --- a/pkg/services/apikey/apikeyimpl/apikey.go +++ b/pkg/services/apikey/apikeyimpl/apikey.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" ) @@ -13,16 +14,34 @@ type Service struct { store store } -func ProvideService(db db.DB, cfg *setting.Cfg) apikey.Service { +func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (apikey.Service, error) { + s := &Service{} if cfg.IsFeatureToggleEnabled(featuremgmt.FlagNewDBLibrary) { - return &Service{ - store: &sqlxStore{ - sess: db.GetSqlxSession(), - cfg: cfg, - }, + s.store = &sqlxStore{ + sess: db.GetSqlxSession(), + cfg: cfg, } } - return &Service{store: &sqlStore{db: db, cfg: cfg}} + s.store = &sqlStore{db: db, cfg: cfg} + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: apikey.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return s, err + } + + return s, nil +} + +func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return s.store.Count(ctx, scopeParams) } func (s *Service) GetAPIKeys(ctx context.Context, query *apikey.GetApiKeysQuery) error { @@ -49,3 +68,24 @@ func (s *Service) AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error { func (s *Service) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error { return s.store.UpdateAPIKeyLastUsedDate(ctx, tokenID) } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.ApiKey) + limits.Set(orgQuotaTag, cfg.Quota.Org.ApiKey) + return limits, nil +} diff --git a/pkg/services/apikey/apikeyimpl/sqlx_store.go b/pkg/services/apikey/apikeyimpl/sqlx_store.go index 9401a975931..b9935a58123 100644 --- a/pkg/services/apikey/apikeyimpl/sqlx_store.go +++ b/pkg/services/apikey/apikeyimpl/sqlx_store.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/setting" ) @@ -142,3 +143,35 @@ func (ss *sqlxStore) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64 _, err := ss.sess.Exec(ctx, `UPDATE api_key SET last_used_at=? WHERE id=?`, &now, tokenID) return err } + +func (ss *sqlxStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key`); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key WHERE org_id = ?`, scopeParams.OrgID); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} diff --git a/pkg/services/apikey/apikeyimpl/store.go b/pkg/services/apikey/apikeyimpl/store.go index 33b8159e7cc..54988660d08 100644 --- a/pkg/services/apikey/apikeyimpl/store.go +++ b/pkg/services/apikey/apikeyimpl/store.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/quota" ) type store interface { @@ -15,4 +16,6 @@ type store interface { GetApiKeyByName(ctx context.Context, query *apikey.GetByNameQuery) error GetAPIKeyByHash(ctx context.Context, hash string) (*apikey.APIKey, error) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error + + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } diff --git a/pkg/services/apikey/apikeyimpl/xorm_store.go b/pkg/services/apikey/apikeyimpl/xorm_store.go index fad3bb89401..bf2ba4ce6d4 100644 --- a/pkg/services/apikey/apikeyimpl/xorm_store.go +++ b/pkg/services/apikey/apikeyimpl/xorm_store.go @@ -11,6 +11,8 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -174,3 +176,47 @@ func (ss *sqlStore) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) return nil }) } + +func (ss *sqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM api_key" + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM api_key WHERE org_id = ?" + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} diff --git a/pkg/services/apikey/model.go b/pkg/services/apikey/model.go index 82acaf3b77e..9563377760b 100644 --- a/pkg/services/apikey/model.go +++ b/pkg/services/apikey/model.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -64,3 +65,8 @@ type GetByIDQuery struct { ApiKeyId int64 Result *APIKey } + +const ( + QuotaTargetSrv quota.TargetSrv = "api_key" + QuotaTarget quota.Target = "api_key" +) diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index dfbd80c8064..f261e33bcd1 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -41,19 +42,38 @@ type UserAuthTokenService struct { log log.Logger } +type ActiveTokenService interface { + ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) +} + type ActiveAuthTokenService struct { cfg *setting.Cfg sqlStore db.DB } -func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB) *ActiveAuthTokenService { - return &ActiveAuthTokenService{ +func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaService quota.Service) (*ActiveAuthTokenService, error) { + s := &ActiveAuthTokenService{ cfg: cfg, sqlStore: sqlStore, } + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.ActiveTokenCount, + }); err != nil { + return s, err + } + + return s, nil } -func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, error) { +func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { var count int64 var err error err = a.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { @@ -66,7 +86,14 @@ func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, e return err }) - return count, err + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u := "a.Map{} + u.Set(tag, count) + + return u, err } func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { @@ -472,3 +499,19 @@ func hashToken(token string) string { hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) return hex.EncodeToString(hashBytes[:]) } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.Session) + return limits, nil +} diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index a2e86b79e42..16886d7b439 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -40,8 +41,12 @@ func TestUserAuthToken(t *testing.T) { userToken := createToken() t.Run("Can count active tokens", func(t *testing.T) { - count, err := ctx.activeTokenService.ActiveTokenCount(context.Background()) + m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + require.NoError(t, err) + count, ok := m.Get(tag) + require.True(t, ok) require.Equal(t, int64(1), count) }) @@ -208,8 +213,12 @@ func TestUserAuthToken(t *testing.T) { require.Nil(t, notGood) t.Run("should not find active token when expired", func(t *testing.T) { - count, err := ctx.activeTokenService.ActiveTokenCount(context.Background()) + m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + require.NoError(t, err) + count, ok := m.Get(tag) + require.True(t, ok) require.Equal(t, int64(0), count) }) }) diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go index 799b3e68b16..afc5b566c48 100644 --- a/pkg/services/auth/model.go +++ b/pkg/services/auth/model.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" ) type userAuthToken struct { @@ -71,3 +72,8 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { return nil } + +const ( + QuotaTargetSrv quota.TargetSrv = "auth" + QuotaTarget quota.Target = "session" +) diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index 12691f8ed5e..f491d645bdc 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) @@ -64,9 +65,9 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re return response.Error(http.StatusUnprocessableEntity, "Dashboard must be set", nil) } - limitReached, err := api.quotaService.QuotaReached(c, "dashboard") + limitReached, err := api.quotaService.QuotaReached(c, dashboards.QuotaTargetSrv) if err != nil { - return response.Error(500, "failed to get quota", err) + return response.Err(err) } if limitReached { @@ -83,12 +84,12 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re } type QuotaService interface { - QuotaReached(c *models.ReqContext, target string) (bool, error) + QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) } -type quotaServiceFunc func(c *models.ReqContext, target string) (bool, error) +type quotaServiceFunc func(c *models.ReqContext, target quota.TargetSrv) (bool, error) -func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target string) (bool, error) { +func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return fn(c, target) } diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 77085c0c01a..d688e019109 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web/webtest" "github.com/stretchr/testify/require" @@ -165,10 +166,10 @@ func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport. return nil, nil } -func quotaReached(c *models.ReqContext, target string) (bool, error) { +func quotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return true, nil } -func quotaNotReached(c *models.ReqContext, target string) (bool, error) { +func quotaNotReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return false, nil } diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 82f4eaa0850..78428d39700 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" ) // DashboardService is a service for operating on dashboards. @@ -77,6 +78,7 @@ type Store interface { ValidateDashboardBeforeSave(ctx context.Context, dashboard *models.Dashboard, overwrite bool) (bool, error) DeleteACLByUser(context.Context, int64) error + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) // CountDashboardsInFolder returns the number of dashboards associated with // the given parent folder ID. CountDashboardsInFolder(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) diff --git a/pkg/services/dashboards/database/acl_test.go b/pkg/services/dashboards/database/acl_test.go index 3836bfdb01e..86ef2df3e3a 100644 --- a/pkg/services/dashboards/database/acl_test.go +++ b/pkg/services/dashboards/database/acl_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -26,7 +27,10 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { setup := func(t *testing.T) { sqlStore = db.InitTestDB(t) - dashboardStore = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") childDash = insertTestDashboard(t, dashboardStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp") diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 321e7eab7c0..2cfc1d0299e 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -16,6 +16,8 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -42,8 +44,23 @@ type DashboardTag struct { // DashboardStore implements the Store interface var _ dashboards.Store = (*DashboardStore)(nil) -func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service) *DashboardStore { - return &DashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} +func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (*DashboardStore, error) { + s := &DashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return nil, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: dashboards.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Count, + }); err != nil { + return nil, err + } + + return s, nil } func (d *DashboardStore) emitEntityEvent() bool { @@ -291,6 +308,50 @@ func (d *DashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context }) } +func (d *DashboardStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.GetDialect().BooleanStr(false)) + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.GetDialect().BooleanStr(false)) + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} + func getExistingDashboardByIdOrUidForUpdate(sess *db.Session, dash *models.Dashboard, dialect migrator.Dialect, overwrite bool) (bool, error) { dashWithIdExists := false isParentFolderChanged := false @@ -1018,6 +1079,27 @@ func (d *DashboardStore) GetDashboardTags(ctx context.Context, query *models.Get }) } +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) + if err != nil { + return "a.Map{}, err + } + orgQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) + if err != nil { + return "a.Map{}, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.Dashboard) + limits.Set(orgQuotaTag, cfg.Quota.Org.Dashboard) + return limits, nil +} + // This will be updated to take CountDashboardsInFolderQuery as an argument and // lookup dashboards using the ParentFolderUID when the NestedFolder // implementation is complete. diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 8f104a670ff..97b12665d52 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -33,7 +34,10 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup := func() { sqlStore = db.InitTestDB(t) sqlStore.Cfg.RBACEnabled = false - dashboardStore = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp") childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, folder.Id, false, "prod", "webapp") @@ -186,7 +190,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2 := func() { sqlStore = db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod") @@ -291,7 +297,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup3 := func() { sqlStore = db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") insertTestDashboard(t, dashboardStore, "folder in another org", 2, 0, true, "prod") @@ -473,7 +481,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { var sqlStore *sqlstore.SQLStore var folder1, folder2 *models.Dashboard sqlStore = db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder2 = insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") _ = insertTestDashboard(t, dashboardStore, title, orgId, folder2.Id, false, "prod") folder1 = insertTestDashboard(t, dashboardStore, title, orgId, 0, true, "prod") @@ -488,7 +498,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByUID", func(t *testing.T) { var orgId int64 = 1 sqlStore := db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder := insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.Id, false, "prod") @@ -512,7 +524,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByID", func(t *testing.T) { var orgId int64 = 1 sqlStore := db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder := insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.Id, false, "prod") diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index 35e7d8e18de..2bfd0feb0cf 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" ) @@ -18,7 +19,9 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { t.Skip("skipping integration test") } sqlStore := db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folderCmd := models.SaveDashboardCommand{ OrgId: 1, diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 5163e0d8d90..4f63c4aef3d 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/publicdashboards/database" publicDashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/star" @@ -42,7 +43,10 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) starService = starimpl.ProvideService(sqlStore, cfg) - dashboardStore = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp") insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.Id, false, "prod") @@ -585,7 +589,9 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - dashboardStore := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) pluginId := "test-app" appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId) @@ -597,7 +603,7 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test OrgId: 1, } - err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query) + err = dashboardStore.GetDashboardsByPluginID(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) } @@ -609,7 +615,9 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - dashboardStore := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) dashA := insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) @@ -660,7 +668,9 @@ func TestIntegrationDashboard_Filter(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - dashboardStore := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) qNoFilter := &models.FindPersistedDashboardsQuery{ diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 21c184cff5c..4c88e2669db 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -4,6 +4,7 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -30,6 +31,11 @@ type DashboardSearchProjection struct { SortMeta int64 } +const ( + QuotaTargetSrv quota.TargetSrv = "dashboard" + QuotaTarget quota.Target = "dashboard" +) + type CountDashboardsInFolderQuery struct { FolderUID string } diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 5c5c369d864..210085b565d 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -42,7 +43,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { }), } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardNotFound, err) }) @@ -62,7 +63,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: false, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardNotFound, err) }) @@ -104,7 +105,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sqlStore) + err := callSaveWithError(t, cmd, sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, int64(0), sc.dashboardGuardianMock.DashId) @@ -124,7 +125,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.otherSavedFolder.Id, sc.dashboardGuardianMock.DashId) @@ -144,7 +145,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -165,7 +166,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -186,7 +187,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -207,7 +208,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -228,7 +229,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -249,7 +250,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -270,7 +271,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -291,7 +292,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -432,7 +433,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardFolderNotFound, err) }) @@ -448,7 +449,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) }) @@ -488,7 +489,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) }) @@ -527,7 +528,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) @@ -543,7 +544,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) @@ -559,7 +560,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) }) @@ -647,7 +648,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err) }) @@ -711,7 +712,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -727,7 +728,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -743,7 +744,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -759,7 +760,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -774,7 +775,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameAsFolder, err) }) @@ -789,7 +790,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardFolderWithSameNameAsDashboard, err) }) }) @@ -821,7 +822,9 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled sqlStore := db.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -878,7 +881,9 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -892,12 +897,14 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore return res } -func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore db.DB) error { +func callSaveWithError(t *testing.T, cmd models.SaveDashboardCommand, sqlStore db.DB) error { dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -905,7 +912,7 @@ func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore db.DB) error { accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), ) - _, err := service.SaveDashboard(context.Background(), &dto, false) + _, err = service.SaveDashboard(context.Background(), &dto, false) return err } @@ -934,7 +941,9 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -972,7 +981,9 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *mo cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 5824d5332db..2bd9ff1284c 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -6,6 +6,7 @@ import ( context "context" models "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" ) @@ -473,6 +474,10 @@ type mockConstructorTestingTNewFakeDashboardStore interface { Cleanup(func()) } +func (_m *FakeDashboardStore) Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) { + return nil, nil +} + // NewFakeDashboardStore creates a new instance of FakeDashboardStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. func NewFakeDashboardStore(t mockConstructorTestingTNewFakeDashboardStore) *FakeDashboardStore { mock := &FakeDashboardStore{} diff --git a/pkg/services/datasources/models.go b/pkg/services/datasources/models.go index 9697c739cbc..fec4db4ade7 100644 --- a/pkg/services/datasources/models.go +++ b/pkg/services/datasources/models.go @@ -4,6 +4,7 @@ import ( "time" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -193,3 +194,8 @@ type DatasourcesPermissionFilterQuery struct { Datasources []*DataSource Result []*DataSource } + +const ( + QuotaTargetSrv quota.TargetSrv = "data_source" + QuotaTarget quota.Target = "data_source" +) diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 064a3431029..3b4bb78e01e 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/setting" @@ -52,7 +53,8 @@ type cachedRoundTripper struct { func ProvideService( db db.DB, secretsService secrets.Service, secretsStore kvstore.SecretsKVStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService, -) *Service { + quotaService quota.Service, +) (*Service, error) { dslogger := log.New("datasources") store := &SqlStore{db: db, logger: dslogger} s := &Service{ @@ -73,7 +75,23 @@ func ProvideService( ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store)) ac.RegisterScopeAttributeResolver(NewIDScopeResolver(store)) - return s + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return nil, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: datasources.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return nil, err + } + return s, nil +} + +func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return s.SQLStore.Count(ctx, scopeParams) } // DataSourceRetriever interface for retrieving a datasource. @@ -591,3 +609,24 @@ func (s *Service) fillWithSecureJSONData(ctx context.Context, cmd *datasources.U return nil } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.OrgScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.DataSource) + limits.Set(orgQuotaTag, cfg.Quota.Org.DataSource) + return limits, nil +} diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index e12e4c3ac56..b06cb9913fa 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -21,6 +21,7 @@ import ( acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" @@ -200,7 +201,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) require.NoError(t, err) @@ -235,7 +238,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -284,7 +289,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -330,7 +337,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -373,7 +382,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -406,7 +417,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -473,7 +486,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, Url: "http://k8s:8001", @@ -507,7 +522,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Type: datasources.DS_ES, @@ -544,7 +561,9 @@ func TestService_getTimeout(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) for _, tc := range testCases { ds := &datasources.DataSource{ @@ -565,7 +584,9 @@ func TestService_GetDecryptedValues(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) jsonData := map[string]string{ "password": "securePassword", @@ -591,7 +612,9 @@ func TestService_GetDecryptedValues(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) jsonData := map[string]string{ "password": "securePassword", diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index 2074889ce64..9737da64622 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -16,6 +16,8 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" ) @@ -29,6 +31,8 @@ type Store interface { AddDataSource(context.Context, *datasources.AddDataSourceCommand) error UpdateDataSource(context.Context, *datasources.UpdateDataSourceCommand) error GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) error + + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } type SqlStore struct { @@ -171,6 +175,50 @@ func (ss *SqlStore) DeleteDataSource(ctx context.Context, cmd *datasources.Delet }) } +func (ss *SqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM data_source" + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM data_source WHERE org_id=?" + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.OrgScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} + func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataSourceCommand) error { return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { existing := datasources.DataSource{OrgId: cmd.OrgId, Name: cmd.Name} diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 85c1dfaf04e..d79a838535a 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/assert" @@ -583,7 +584,8 @@ func TestIntegrationGetChildren(t *testing.T) { func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { t.Helper() - orgService := orgimpl.ProvideService(db, db.Cfg) + orgService, err := orgimpl.ProvideService(db, db.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) orgID, err := orgService.GetOrCreate(context.Background(), "test-org") require.NoError(t, err) t.Cleanup(func() { diff --git a/pkg/services/guardian/accesscontrol_guardian_test.go b/pkg/services/guardian/accesscontrol_guardian_test.go index 8660e1cf2b5..39c0496a19c 100644 --- a/pkg/services/guardian/accesscontrol_guardian_test.go +++ b/pkg/services/guardian/accesscontrol_guardian_test.go @@ -19,6 +19,7 @@ import ( dashdb "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" @@ -591,7 +592,9 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce toSave.SetUid(uid) // seed dashboard - dashStore := dashdb.ProvideDashboardStore(store, store.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(store, store.Cfg)) + quotaService := quotatest.New(false, nil) + dashStore, err := dashdb.ProvideDashboardStore(store, store.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(store, store.Cfg), quotaService) + require.NoError(t, err) dash, err := dashStore.SaveDashboard(context.Background(), models.SaveDashboardCommand{ Dashboard: toSave.Data, UserId: 1, @@ -603,7 +606,8 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() teamSvc := teamimpl.ProvideService(store, store.Cfg) - userSvc := userimpl.ProvideService(store, nil, store.Cfg, nil, nil) + userSvc, err := userimpl.ProvideService(store, nil, store.Cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( setting.NewCfg(), routing.NewRouteRegister(), store, ac, license, &dashboards.FakeDashboardStore{}, ac, teamSvc, userSvc) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 3b6a3975ed5..8ecc4399794 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -278,7 +279,9 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash cfg.RBACEnabled = false features := featuremgmt.WithFeatures() cfg.IsFeatureToggleEnabled = features.IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := acmock.New() folderPermissions := acmock.NewMockedPermissionsService() @@ -304,7 +307,9 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.S ac := acmock.New() folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) d := dashboardservice.ProvideDashboardService( cfg, dashboardStore, nil, @@ -405,7 +410,9 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := org.RoleAdmin sqlStore := db.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) features := featuremgmt.WithFeatures() ac := acmock.New().WithDisabled() // TODO: Update tests to work with rbac @@ -442,7 +449,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Login: userInDbName, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err = sqlStore.CreateUser(context.Background(), cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index a422dc729a2..e4ca222f89d 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -26,6 +26,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -691,7 +692,9 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := acmock.New() service := dashboardservice.ProvideDashboardService( @@ -715,7 +718,9 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user. features := featuremgmt.WithFeatures() folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) d := dashboardservice.ProvideDashboardService(cfg, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac) s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, features, folderPermissions, nil) @@ -808,7 +813,9 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := org.RoleAdmin sqlStore, cfg := db.InitTestDBwithCfg(t) - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) features := featuremgmt.WithFeatures() ac := acmock.New() @@ -847,7 +854,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Login: userInDbName, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err = sqlStore.CreateUser(context.Background(), cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 6e68e00c0a5..1c28ac1423c 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -71,13 +71,17 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser return login.ErrSignupNotAllowed } - limitReached, errLimit := ls.QuotaService.QuotaReached(cmd.ReqContext, "user") - if errLimit != nil { - cmd.ReqContext.Logger.Warn("Error getting user quota.", "error", errLimit) - return login.ErrGettingUserQuota - } - if limitReached { - return login.ErrUsersQuotaReached + // we may insert in both user and org_user tables + // therefore we need to query check quota for both user and org services + for _, srv := range []string{user.QuotaTargetSrv, org.QuotaTargetSrv} { + limitReached, errLimit := ls.QuotaService.QuotaReached(cmd.ReqContext, quota.TargetSrv(srv)) + if errLimit != nil { + cmd.ReqContext.Logger.Warn("Error getting user quota.", "error", errLimit) + return login.ErrGettingUserQuota + } + if limitReached { + return login.ErrUsersQuotaReached + } } result, errCreateUser := ls.createUser(extUser) diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index 2655a7d5c3a..edd9bade8d6 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/stretchr/testify/assert" @@ -26,7 +26,7 @@ func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - QuotaService: "aimpl.Service{}, + QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, SQLStore: nil, userService: usertest.NewUserServiceFake(), @@ -51,7 +51,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { orgService.ExpectedOrgListResponse = createResponseWithOneErrLastOrgAdminItem() login := Implementation{ - QuotaService: "aimpl.Service{}, + QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, SQLStore: nil, userService: usertest.NewUserServiceFake(), @@ -66,7 +66,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { func Test_teamSync(t *testing.T) { authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - QuotaService: "aimpl.Service{}, + QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, } diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index bf0c3953c8f..ffca5f0c293 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -145,3 +145,28 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { alertRules: api.AlertRules, }), m) } + +func (api *API) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + if orgUsage, err := api.RuleStore.Count(ctx, scopeParams.OrgID); err != nil { + return u, err + } else { + tag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.OrgScope) + if err != nil { + return u, err + } + u.Set(tag, orgUsage) + } + + if globalUsage, err := api.RuleStore.Count(ctx, 0); err != nil { + return u, err + } else { + tag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, globalUsage) + } + + return u, nil +} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 9229bd40f78..433d3160ae5 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -393,7 +393,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod } if len(finalChanges.New) > 0 { - limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{ + limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, ngmodels.QuotaTargetSrv, "a.ScopeParameters{ OrgID: c.OrgID, UserID: c.UserID, }) // alert rule is table name diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index bb8f59c7412..60341ae2594 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -23,4 +23,6 @@ type RuleStore interface { // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) + + Count(ctx context.Context, orgID int64) (int64, error) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 1572cd7b850..83ee27f2a74 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -460,6 +461,11 @@ func (g RulesGroup) SortByGroupIndex() { }) } +const ( + QuotaTargetSrv quota.TargetSrv = "ngalert" + QuotaTarget quota.Target = "alert_rule" +) + type ruleKeyContextKey struct{} func WithRuleKey(ctx context.Context, ruleKey AlertRuleKey) context.Context { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index d61b07fe9c0..12cc93d6ca2 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -239,6 +239,19 @@ func (ng *AlertNG) init() error { } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) + defaultLimits, err := readQuotaConfig(ng.Cfg) + if err != nil { + return err + } + + if err := ng.QuotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: models.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: api.Usage, + }); err != nil { + return err + } + log.RegisterContextualLogProvider(func(ctx context.Context) ([]interface{}, bool) { key, ok := models.RuleKeyFromContext(ctx) if !ok { @@ -308,3 +321,32 @@ func (ng *AlertNG) IsDisabled() bool { } return !ng.Cfg.UnifiedAlerting.IsEnabled() } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + var alertOrgQuota int64 + var alertGlobalQuota int64 + + if cfg.UnifiedAlerting.IsEnabled() { + alertOrgQuota = cfg.Quota.Org.AlertRule + alertGlobalQuota = cfg.Quota.Global.AlertRule + } + + globalQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.OrgScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, alertGlobalQuota) + limits.Set(orgQuotaTag, alertOrgQuota) + return limits, nil +} diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 97d406a8214..bfbbadb3646 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -48,7 +48,7 @@ type RuleStore interface { // //go:generate mockery --name QuotaChecker --structname MockQuotaChecker --inpackage --filename quota_checker_mock.go --with-expecter type QuotaChecker interface { - CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) + CheckQuotaReached(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) } // PersistConfig validates to config before eventually persisting it if no error occurs diff --git a/pkg/services/ngalert/provisioning/quota_checker_mock.go b/pkg/services/ngalert/provisioning/quota_checker_mock.go index f545dd1b5ec..1dac163c33d 100644 --- a/pkg/services/ngalert/provisioning/quota_checker_mock.go +++ b/pkg/services/ngalert/provisioning/quota_checker_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.12.0. DO NOT EDIT. +// Code generated by mockery v2.14.0. DO NOT EDIT. package provisioning @@ -7,8 +7,6 @@ import ( quota "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" - - testing "testing" ) // MockQuotaChecker is an autogenerated mock type for the QuotaChecker type @@ -25,18 +23,18 @@ func (_m *MockQuotaChecker) EXPECT() *MockQuotaChecker_Expecter { } // CheckQuotaReached provides a mock function with given fields: ctx, target, scopeParams -func (_m *MockQuotaChecker) CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) { +func (_m *MockQuotaChecker) CheckQuotaReached(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { ret := _m.Called(ctx, target, scopeParams) var r0 bool - if rf, ok := ret.Get(0).(func(context.Context, string, *quota.ScopeParameters) bool); ok { + if rf, ok := ret.Get(0).(func(context.Context, quota.TargetSrv, *quota.ScopeParameters) bool); ok { r0 = rf(ctx, target, scopeParams) } else { r0 = ret.Get(0).(bool) } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string, *quota.ScopeParameters) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, quota.TargetSrv, *quota.ScopeParameters) error); ok { r1 = rf(ctx, target, scopeParams) } else { r1 = ret.Error(1) @@ -51,16 +49,16 @@ type MockQuotaChecker_CheckQuotaReached_Call struct { } // CheckQuotaReached is a helper method to define mock.On call -// - ctx context.Context -// - target string -// - scopeParams *quota.ScopeParameters +// - ctx context.Context +// - target quota.TargetSrv +// - scopeParams *quota.ScopeParameters func (_e *MockQuotaChecker_Expecter) CheckQuotaReached(ctx interface{}, target interface{}, scopeParams interface{}) *MockQuotaChecker_CheckQuotaReached_Call { return &MockQuotaChecker_CheckQuotaReached_Call{Call: _e.mock.On("CheckQuotaReached", ctx, target, scopeParams)} } -func (_c *MockQuotaChecker_CheckQuotaReached_Call) Run(run func(ctx context.Context, target string, scopeParams *quota.ScopeParameters)) *MockQuotaChecker_CheckQuotaReached_Call { +func (_c *MockQuotaChecker_CheckQuotaReached_Call) Run(run func(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters)) *MockQuotaChecker_CheckQuotaReached_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(*quota.ScopeParameters)) + run(args[0].(context.Context), args[1].(quota.TargetSrv), args[2].(*quota.ScopeParameters)) }) return _c } @@ -70,8 +68,13 @@ func (_c *MockQuotaChecker_CheckQuotaReached_Call) Return(_a0 bool, _a1 error) * return _c } -// NewMockQuotaChecker creates a new instance of MockQuotaChecker. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockQuotaChecker(t testing.TB) *MockQuotaChecker { +type mockConstructorTestingTNewMockQuotaChecker interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockQuotaChecker creates a new instance of MockQuotaChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockQuotaChecker(t mockConstructorTestingTNewMockQuotaChecker) *MockQuotaChecker { mock := &MockQuotaChecker{} mock.Mock.Test(t) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 1d2084857e3..3a9cbac848d 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -268,6 +269,29 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR }) } +// Count returns either the number of the alert rules under a specific org (if orgID is not zero) +// or the number of all the alert rules +func (st DBstore) Count(ctx context.Context, orgID int64) (int64, error) { + type result struct { + Count int64 + } + + r := result{} + err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) as count from alert_rule" + args := make([]interface{}, 0) + if orgID != 0 { + rawSQL += " WHERE org_id=?" + args = append(args, orgID) + } + if _, err := sess.SQL(rawSQL, args...).Get(&r); err != nil { + return err + } + return nil + }) + return r.Count, err +} + func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) { var interval int64 = 0 return interval, st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 3cb2f1b7210..c2139c5d2bf 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -339,3 +339,7 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org } return result, nil } + +func (f *RuleStore) Count(ctx context.Context, orgID int64) (int64, error) { + return 0, nil +} diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 6862ed83361..d8083bdb2e7 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -31,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -75,7 +76,9 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := db.InitTestDB(tb) secretsService := secretsManager.SetupTestService(tb, database.ProvideSecretsStore(sqlStore)) - dashboardStore := databasestore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := databasestore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(tb, err) ac := acmock.New() features := featuremgmt.WithFeatures() @@ -92,7 +95,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, folderService := folderimpl.ProvideService(ac, bus, cfg, dashboardService, dashboardStore, features, folderPermissions, nil) ng, err := ngalert.ProvideService( - cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil, + cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, annotationstest.NewFakeAnnotationsRepo(), ) require.NoError(tb, err) diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index 4dc0e2a0a2b..d6956ce2384 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -204,3 +204,9 @@ func (o ByOrgName) Less(i, j int) bool { return o[i].Name < o[j].Name } + +const ( + QuotaTargetSrv string = "org" + OrgQuotaTarget string = "org" + OrgUserQuotaTarget string = "org_user" +) diff --git a/pkg/services/org/orgimpl/org.go b/pkg/services/org/orgimpl/org.go index ca5539eb9a5..ed6c3bc506a 100644 --- a/pkg/services/org/orgimpl/org.go +++ b/pkg/services/org/orgimpl/org.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -18,9 +19,9 @@ type Service struct { log log.Logger } -func ProvideService(db db.DB, cfg *setting.Cfg) org.Service { +func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (org.Service, error) { log := log.New("org service") - return &Service{ + s := &Service{ store: &sqlStore{ db: db, dialect: db.GetDialect(), @@ -30,6 +31,24 @@ func ProvideService(db db.DB, cfg *setting.Cfg) org.Service { cfg: cfg, log: log, } + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: quota.TargetSrv(org.QuotaTargetSrv), + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return s, nil + } + return s, nil +} + +func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return s.store.Count(ctx, scopeParams) } func (s *Service) GetIDForNewUser(ctx context.Context, cmd org.GetOrgIDForNewUserCommand) (int64, error) { @@ -179,3 +198,31 @@ func (s *Service) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) func (s *Service) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { return s.store.SearchOrgUsers(ctx, query) } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope) + if err != nil { + return limits, err + } + userTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.Org) + // users per org + limits.Set(orgQuotaTag, cfg.Quota.Org.User) + // orgs per user + limits.Set(userTag, cfg.Quota.User.Org) + return limits, nil +} diff --git a/pkg/services/org/orgimpl/org_test.go b/pkg/services/org/orgimpl/org_test.go index 9d9b48c862c..410bbf5a255 100644 --- a/pkg/services/org/orgimpl/org_test.go +++ b/pkg/services/org/orgimpl/org_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -135,3 +136,7 @@ func (f *FakeOrgStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgU func (f *FakeOrgStore) RemoveOrgUser(ctx context.Context, cmd *org.RemoveOrgUserCommand) error { return f.ExpectedError } + +func (f *FakeOrgStore) Count(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { + return nil, nil +} diff --git a/pkg/services/org/orgimpl/store.go b/pkg/services/org/orgimpl/store.go index afc900223b5..09936a3195d 100644 --- a/pkg/services/org/orgimpl/store.go +++ b/pkg/services/org/orgimpl/store.go @@ -14,6 +14,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -42,6 +44,8 @@ type store interface { GetByName(context.Context, *org.GetOrgByNameQuery) (*org.Org, error) SearchOrgUsers(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) RemoveOrgUser(context.Context, *org.RemoveOrgUserCommand) error + + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } type sqlStore struct { @@ -395,6 +399,72 @@ func (ss *sqlStore) AddOrgUser(ctx context.Context, cmd *org.AddOrgUserCommand) }) } +func (ss *sqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) as count from org" + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM (SELECT user_id FROM org_user WHERE org_id=? AND user_id IN (SELECT id AS user_id FROM %s WHERE is_service_account=%s)) as subq", + ss.db.GetDialect().Quote("user"), + ss.db.GetDialect().BooleanStr(false), + ) + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + } + + if scopeParams.UserID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + // should we exclude service accounts? + rawSQL := "SELECT COUNT(*) AS count FROM org_user WHERE user_id=?" + if _, err := sess.SQL(rawSQL, scopeParams.UserID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} + func setUsingOrgInTransaction(sess *db.Session, userID int64, orgID int64) error { user := user.User{ ID: userID, diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index c5aa5caa787..4a1eb4ff142 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -28,6 +28,7 @@ import ( publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -300,7 +301,8 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) } // create dashboard - dashboardStoreService := dashboardStore.ProvideDashboardStore(db, db.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, db.Cfg)) + dashboardStoreService, err := dashboardStore.ProvideDashboardStore(db, db.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, db.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) dashboard, err := dashboardStoreService.SaveDashboard(context.Background(), saveDashboardCmd) require.NoError(t, err) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 6c66764b304..b217e324a6c 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -35,7 +36,9 @@ func TestIntegrationListPublicDashboard(t *testing.T) { t.Skip("skipping integration test") } sqlStore, cfg := db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) publicdashboardStore := ProvideStore(sqlStore) var orgId int64 = 1 @@ -78,7 +81,10 @@ func TestIntegrationFindDashboard(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -105,7 +111,10 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -175,7 +184,10 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -237,7 +249,10 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -299,10 +314,12 @@ func TestIntegrationFindByAccessToken(t *testing.T) { var dashboardStore *dashboardsDB.DashboardStore var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -369,7 +386,10 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, true) @@ -436,10 +456,13 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var anotherSavedDashboard *models.Dashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, 0, true) @@ -529,10 +552,13 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { var dashboardStore *dashboardsDB.DashboardStore var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -599,10 +625,12 @@ func TestIntegrationDelete(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var savedPublicDashboard *PublicDashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.Uid, savedDashboard.OrgId, true) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 884b82d8d59..79baf710054 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" @@ -355,7 +356,8 @@ const ( func TestGetQueryDataResponse(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) service := &PublicDashboardServiceImpl{ @@ -738,7 +740,8 @@ func TestGetAnnotations(t *testing.T) { func TestGetMetricRequest(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) publicDashboard := &PublicDashboard{ @@ -811,7 +814,8 @@ func TestGetUniqueDashboardDatasourceUids(t *testing.T) { func TestBuildMetricRequest(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -1022,7 +1026,8 @@ func TestBuildMetricRequest(t *testing.T) { func TestBuildAnonymousUser(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) //publicdashboardStore := database.ProvideStore(sqlStore) //service := &PublicDashboardServiceImpl{ diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index caafbad220e..fcf700b36c4 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -125,7 +126,9 @@ func TestGetPublicDashboard(t *testing.T) { func TestCreatePublicDashboard(t *testing.T) { t.Run("Create public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -147,7 +150,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err := service.Create(context.Background(), SignedInUser, dto) + _, err = service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -171,7 +174,9 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Validate pubdash has default time setting value", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -191,7 +196,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err := service.Create(context.Background(), SignedInUser, dto) + _, err = service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -201,7 +206,9 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Validate pubdash whose dashboard has template variables returns error", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) templateVars := make([]map[string]interface{}, 1) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, templateVars, nil) @@ -222,7 +229,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err := service.Create(context.Background(), SignedInUser, dto) + _, err = service.Create(context.Background(), SignedInUser, dto) require.Error(t, err) }) @@ -265,7 +272,8 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Returns error if public dashboard exists", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -316,7 +324,9 @@ func TestCreatePublicDashboard(t *testing.T) { func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -378,7 +388,9 @@ func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating set empty time settings", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index 32a17fcc787..af1d87c9263 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -23,6 +23,7 @@ import ( fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" dsSvc "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -389,7 +390,9 @@ func setup(t *testing.T) *testContext { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) ss := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) ssvc := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - ds := dsSvc.ProvideService(nil, ssvc, ss, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + ds, err := dsSvc.ProvideService(nil, ssvc, ss, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) fakeDatasourceService := &fakeDatasources.FakeDataSourceService{ DataSources: nil, SimulatePluginFailure: false, diff --git a/pkg/services/quota/context.go b/pkg/services/quota/context.go new file mode 100644 index 00000000000..2342d53d476 --- /dev/null +++ b/pkg/services/quota/context.go @@ -0,0 +1,42 @@ +package quota + +import ( + "context" + "sync" +) + +type Context struct { + context.Context + TargetToSrv *TargetToSrv +} + +func FromContext(ctx context.Context, targetToSrv *TargetToSrv) Context { + if targetToSrv == nil { + targetToSrv = NewTargetToSrv() + } + return Context{Context: ctx, TargetToSrv: targetToSrv} +} + +type TargetToSrv struct { + mutex sync.RWMutex + m map[Target]TargetSrv +} + +func NewTargetToSrv() *TargetToSrv { + return &TargetToSrv{m: make(map[Target]TargetSrv)} +} + +func (m *TargetToSrv) Get(target Target) (TargetSrv, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + srv, ok := m.m[target] + return srv, ok +} + +func (m *TargetToSrv) Set(target Target, srv TargetSrv) { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.m[target] = srv +} diff --git a/pkg/services/quota/model.go b/pkg/services/quota/model.go index d0e69700f68..091c60c4f36 100644 --- a/pkg/services/quota/model.go +++ b/pkg/services/quota/model.go @@ -1,10 +1,216 @@ package quota -import "errors" +import ( + "strings" + "sync" + "time" -var ErrInvalidQuotaTarget = errors.New("invalid quota target") + "github.com/grafana/grafana/pkg/util/errutil" +) + +var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "quota.bad-request") +var ErrInvalidTargetSrv = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-target") +var ErrInvalidScope = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-scope") +var ErrInvalidTarget = errutil.NewBase(errutil.StatusInternal, "quota.invalid-target-table") +var ErrTargetSrvConflict = errutil.NewBase(errutil.StatusBadRequest, "quota.target-srv-conflict") +var ErrDisabled = errutil.NewBase(errutil.StatusForbidden, "quota.disabled", errutil.WithPublicMessage("Quotas not enabled")) +var ErrInvalidTagFormat = errutil.NewBase(errutil.StatusInternal, "quota.invalid-invalid-tag-format") type ScopeParameters struct { OrgID int64 UserID int64 } + +type Scope string + +const ( + GlobalScope Scope = "global" + OrgScope Scope = "org" + UserScope Scope = "user" +) + +func (s Scope) Validate() error { + switch s { + case GlobalScope, OrgScope, UserScope: + return nil + default: + return ErrInvalidScope.Errorf("bad scope: %s", s) + } +} + +type TargetSrv string + +type Target string + +const delimiter = ":" + +// Tag is a string with the format :: +type Tag string + +func NewTag(srv TargetSrv, t Target, scope Scope) (Tag, error) { + if err := scope.Validate(); err != nil { + return "", err + } + + tag := Tag(strings.Join([]string{string(srv), string(t), string(scope)}, delimiter)) + return tag, nil +} + +func (t Tag) split() ([]string, error) { + parts := strings.SplitN(string(t), delimiter, -1) + if len(parts) != 3 { + return nil, ErrInvalidTagFormat.Errorf("tag format should be ^(?\\w):(?\\w):(?\\w)$") + } + + return parts, nil +} + +func (t Tag) GetSrv() (TargetSrv, error) { + parts, err := t.split() + if err != nil { + return "", err + } + return TargetSrv(parts[0]), nil +} + +func (t Tag) GetTarget() (Target, error) { + parts, err := t.split() + if err != nil { + return "", err + } + return Target(parts[1]), nil +} + +func (t Tag) GetScope() (Scope, error) { + parts, err := t.split() + if err != nil { + return "", err + } + return Scope(parts[2]), nil +} + +type Item struct { + Tag Tag + Value int64 +} + +type Map struct { + mutex sync.RWMutex + m map[Tag]int64 +} + +func (m *Map) Set(tag Tag, limit int64) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if len(m.m) == 0 { + m.m = make(map[Tag]int64, 0) + } + m.m[tag] = limit +} + +func (m *Map) Get(tag Tag) (int64, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + limit, ok := m.m[tag] + return limit, ok +} + +func (m *Map) Merge(l2 *Map) { + l2.mutex.RLock() + defer l2.mutex.RUnlock() + + for k, v := range l2.m { + // TODO check for conflicts? + m.Set(k, v) + } +} + +func (m *Map) Iter() <-chan Item { + m.mutex.RLock() + defer m.mutex.RUnlock() + + ch := make(chan Item) + go func() { + defer close(ch) + for t, v := range m.m { + ch <- Item{Tag: t, Value: v} + } + }() + + return ch +} + +func (m *Map) Scopes() (map[Scope]struct{}, error) { + res := make(map[Scope]struct{}) + for item := range m.Iter() { + scope, err := item.Tag.GetScope() + if err != nil { + return nil, err + } + res[scope] = struct{}{} + } + return res, nil +} + +func (m *Map) Services() (map[TargetSrv]struct{}, error) { + res := make(map[TargetSrv]struct{}) + for item := range m.Iter() { + srv, err := item.Tag.GetSrv() + if err != nil { + return nil, err + } + res[srv] = struct{}{} + } + return res, nil +} + +func (m *Map) Targets() (map[Target]struct{}, error) { + res := make(map[Target]struct{}) + for item := range m.Iter() { + target, err := item.Tag.GetTarget() + if err != nil { + return nil, err + } + res[target] = struct{}{} + } + return res, nil +} + +type Quota struct { + Id int64 + OrgId int64 + UserId int64 + Target string + Limit int64 + Created time.Time + Updated time.Time +} + +type QuotaDTO struct { + OrgId int64 `json:"org_id,omitempty"` + UserId int64 `json:"user_id,omitempty"` + Target string `json:"target"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` + Service string `json:"-"` + Scope string `json:"-"` +} + +func (dto QuotaDTO) Tag() (Tag, error) { + return NewTag(TargetSrv(dto.Service), Target(dto.Target), Scope(dto.Scope)) +} + +type UpdateQuotaCmd struct { + Target string `json:"target"` + Limit int64 `json:"limit"` + OrgID int64 `json:"-"` + UserID int64 `json:"-"` +} + +type NewUsageReporter struct { + TargetSrv TargetSrv + DefaultLimits *Map + Reporter UsageReporterFunc +} diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index 90cc46c878b..13045f41de2 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -7,7 +7,24 @@ import ( ) type Service interface { - QuotaReached(c *models.ReqContext, target string) (bool, error) - CheckQuotaReached(ctx context.Context, target string, scopeParams *ScopeParameters) (bool, error) - DeleteByUser(context.Context, int64) error + // GetQuotasByScope returns the quota for the specific scope (global, organization, user) + // If the scope is organization, the ID is expected to be the organisation ID. + // If the scope is user, the id is expected to be the user ID. + GetQuotasByScope(ctx context.Context, scope Scope, ID int64) ([]QuotaDTO, error) + // Update overrides the quota for a specific scope (global, organization, user). + // If the cmd.OrgID is set, then the organization quota are updated. + // If the cmd.UseID is set, then the user quota are updated. + Update(ctx context.Context, cmd *UpdateQuotaCmd) error + // QuotaReached is called by the quota middleware for applying quota enforcement to API handlers + QuotaReached(c *models.ReqContext, targetSrv TargetSrv) (bool, error) + // CheckQuotaReached checks if the quota limitations have been reached for a specific service + CheckQuotaReached(ctx context.Context, targetSrv TargetSrv, scopeParams *ScopeParameters) (bool, error) + // DeleteQuotaForUser deletes custom quota limitations for the user + DeleteQuotaForUser(ctx context.Context, userID int64) error + // DeleteByOrg(ctx context.Context, orgID int64) error + + // RegisterQuotaReporter registers a service UsageReporterFunc, targets and their default limits + RegisterQuotaReporter(e *NewUsageReporter) error } + +type UsageReporterFunc func(ctx context.Context, scopeParams *ScopeParameters) (*Map, error) diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index fb7f9fd6fc1..e435989fbfb 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -2,38 +2,81 @@ package quotaimpl import ( "context" + "fmt" + "sync" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "golang.org/x/sync/errgroup" ) -type Service struct { - store store - authTokenService models.ActiveTokenService - Cfg *setting.Cfg - SQLStore sqlstore.Store - Logger log.Logger +type serviceDisabled struct { } -func ProvideService(db db.DB, cfg *setting.Cfg, tokenService models.ActiveTokenService, ss *sqlstore.SQLStore) quota.Service { - return &Service{ - store: &sqlStore{db: db}, - Cfg: cfg, - authTokenService: tokenService, - SQLStore: ss, - Logger: log.New("quota_service"), +func (s *serviceDisabled) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { + return false, nil +} + +func (s *serviceDisabled) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { + return nil, quota.ErrDisabled +} + +func (s *serviceDisabled) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { + return quota.ErrDisabled +} + +func (s *serviceDisabled) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { + return false, nil +} + +func (s *serviceDisabled) DeleteQuotaForUser(ctx context.Context, userID int64) error { + return quota.ErrDisabled +} + +func (s *serviceDisabled) RegisterQuotaReporter(e *quota.NewUsageReporter) error { + return nil +} + +type service struct { + store store + Cfg *setting.Cfg + Logger log.Logger + + mutex sync.RWMutex + reporters map[quota.TargetSrv]quota.UsageReporterFunc + + defaultLimits *quota.Map + + targetToSrv *quota.TargetToSrv +} + +func ProvideService(db db.DB, cfg *setting.Cfg) quota.Service { + logger := log.New("quota_service") + s := service{ + store: &sqlStore{db: db, logger: logger}, + Cfg: cfg, + Logger: logger, + reporters: make(map[quota.TargetSrv]quota.UsageReporterFunc), + defaultLimits: "a.Map{}, + targetToSrv: quota.NewTargetToSrv(), } + + if s.IsDisabled() { + return &serviceDisabled{} + } + + return &s +} + +func (s *service) IsDisabled() bool { + return !s.Cfg.Quota.Enabled } // QuotaReached checks that quota is reached for a target. Runs CheckQuotaReached and take context and scope parameters from the request context -func (s *Service) QuotaReached(c *models.ReqContext, target string) (bool, error) { - if !s.Cfg.Quota.Enabled { - return false, nil - } +func (s *service) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { // No request context means this is a background service, like LDAP Background Sync if c == nil { return false, nil @@ -46,91 +89,129 @@ func (s *Service) QuotaReached(c *models.ReqContext, target string) (bool, error UserID: c.UserID, } } - return s.CheckQuotaReached(c.Req.Context(), target, params) + return s.CheckQuotaReached(c.Req.Context(), targetSrv, params) +} + +func (s *service) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { + if err := scope.Validate(); err != nil { + return nil, err + } + + q := make([]quota.QuotaDTO, 0) + + scopeParams := quota.ScopeParameters{} + if scope == quota.OrgScope { + scopeParams.OrgID = id + } else if scope == quota.UserScope { + scopeParams.UserID = id + } + + c, err := s.getContext(ctx) + if err != nil { + return nil, err + } + customLimits, err := s.store.Get(c, &scopeParams) + if err != nil { + return nil, err + } + + u, err := s.getUsage(ctx, &scopeParams) + if err != nil { + return nil, err + } + + for item := range s.defaultLimits.Iter() { + limit := item.Value + + scp, err := item.Tag.GetScope() + if err != nil { + return nil, err + } + + if scp != scope { + continue + } + + if targetCustomLimit, ok := customLimits.Get(item.Tag); ok { + limit = targetCustomLimit + } + + target, err := item.Tag.GetTarget() + if err != nil { + return nil, err + } + + srv, err := item.Tag.GetSrv() + if err != nil { + return nil, err + } + + used, _ := u.Get(item.Tag) + q = append(q, quota.QuotaDTO{ + Target: string(target), + Limit: limit, + OrgId: scopeParams.OrgID, + UserId: scopeParams.UserID, + Used: used, + Service: string(srv), + Scope: string(scope), + }) + } + + return q, nil +} + +func (s *service) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { + targetFound := false + knownTargets, err := s.defaultLimits.Targets() + if err != nil { + return err + } + + for t := range knownTargets { + if t == quota.Target(cmd.Target) { + targetFound = true + } + } + if !targetFound { + return quota.ErrInvalidTarget.Errorf("unknown quota target: %s", cmd.Target) + } + + c, err := s.getContext(ctx) + if err != nil { + return err + } + return s.store.Update(c, cmd) } // CheckQuotaReached check that quota is reached for a target. If ScopeParameters are not defined, only global scope is checked -func (s *Service) CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) { - if !s.Cfg.Quota.Enabled { - return false, nil - } - // get the list of scopes that this target is valid for. Org, User, Global - scopes, err := s.getQuotaScopes(target) +func (s *service) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { + targetSrvLimits, err := s.getOverridenLimits(ctx, targetSrv, scopeParams) if err != nil { return false, err } - for _, scope := range scopes { - s.Logger.Debug("Checking quota", "target", target, "scope", scope) - switch scope.Name { - case "global": - if scope.DefaultLimit < 0 { - continue - } - if scope.DefaultLimit == 0 { - return true, nil - } - if target == "session" { - usedSessions, err := s.authTokenService.ActiveTokenCount(ctx) - if err != nil { - return false, err - } + usageReporterFunc, ok := s.getReporter(targetSrv) + if !ok { + return false, quota.ErrInvalidTargetSrv + } + targetUsage, err := usageReporterFunc(ctx, scopeParams) + if err != nil { + return false, err + } - if usedSessions > scope.DefaultLimit { - s.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit) - return true, nil - } - continue + for t, limit := range targetSrvLimits { + switch { + case limit < 0: + continue + case limit == 0: + return true, nil + default: + u, ok := targetUsage.Get(t) + if !ok { + return false, fmt.Errorf("no usage for target:%s", t) } - query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target, UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled()} - // TODO : move GetGlobalQuotaByTarget to a global quota service - if err := s.SQLStore.GetGlobalQuotaByTarget(ctx, &query); err != nil { - return true, err - } - if query.Result.Used >= scope.DefaultLimit { - return true, nil - } - case "org": - if scopeParams == nil { - continue - } - query := models.GetOrgQuotaByTargetQuery{ - OrgId: scopeParams.OrgID, - Target: scope.Target, - Default: scope.DefaultLimit, - UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled(), - } - // TODO: move GetOrgQuotaByTarget from sqlstore to quota store - if err := s.SQLStore.GetOrgQuotaByTarget(ctx, &query); err != nil { - return true, err - } - if query.Result.Limit < 0 { - continue - } - if query.Result.Limit == 0 { - return true, nil - } - - if query.Result.Used >= query.Result.Limit { - return true, nil - } - case "user": - if scopeParams == nil || scopeParams.UserID == 0 { - continue - } - query := models.GetUserQuotaByTargetQuery{UserId: scopeParams.UserID, Target: scope.Target, Default: scope.DefaultLimit, UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled()} - // TODO: move GetUserQuotaByTarget from sqlstore to quota store - if err := s.SQLStore.GetUserQuotaByTarget(ctx, &query); err != nil { - return true, err - } - if query.Result.Limit < 0 { - continue - } - if query.Result.Limit == 0 { - return true, nil - } - - if query.Result.Used >= query.Result.Limit { + if u >= limit { return true, nil } } @@ -138,68 +219,127 @@ func (s *Service) CheckQuotaReached(ctx context.Context, target string, scopePar return false, nil } -func (s *Service) getQuotaScopes(target string) ([]models.QuotaScope, error) { - scopes := make([]models.QuotaScope, 0) - switch target { - case "user": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.User}, - models.QuotaScope{Name: "org", Target: "org_user", DefaultLimit: s.Cfg.Quota.Org.User}, - ) - return scopes, nil - case "org": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.Org}, - models.QuotaScope{Name: "user", Target: "org_user", DefaultLimit: s.Cfg.Quota.User.Org}, - ) - return scopes, nil - case "dashboard": - scopes = append(scopes, - models.QuotaScope{ - Name: "global", - Target: target, - DefaultLimit: s.Cfg.Quota.Global.Dashboard, - }, - models.QuotaScope{ - Name: "org", - Target: target, - DefaultLimit: s.Cfg.Quota.Org.Dashboard, - }, - ) - return scopes, nil - case "data_source": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.DataSource}, - models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.DataSource}, - ) - return scopes, nil - case "api_key": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.ApiKey}, - models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.ApiKey}, - ) - return scopes, nil - case "session": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.Session}, - ) - return scopes, nil - case "alert_rule": // target need to match the respective database name - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.AlertRule}, - models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.AlertRule}, - ) - return scopes, nil - case "file": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.File}, - ) - return scopes, nil - default: - return scopes, quota.ErrInvalidQuotaTarget +func (s *service) DeleteQuotaForUser(ctx context.Context, userID int64) error { + c, err := s.getContext(ctx) + if err != nil { + return err } + return s.store.DeleteByUser(c, userID) } -func (s *Service) DeleteByUser(ctx context.Context, userID int64) error { - return s.store.DeleteByUser(ctx, userID) +func (s *service) RegisterQuotaReporter(e *quota.NewUsageReporter) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + _, ok := s.reporters[e.TargetSrv] + if ok { + return quota.ErrTargetSrvConflict.Errorf("target service: %s already exists", e.TargetSrv) + } + + s.reporters[e.TargetSrv] = e.Reporter + + for item := range e.DefaultLimits.Iter() { + target, err := item.Tag.GetTarget() + if err != nil { + return err + } + srv, err := item.Tag.GetSrv() + if err != nil { + return err + } + s.targetToSrv.Set(target, srv) + s.defaultLimits.Set(item.Tag, item.Value) + } + + return nil +} + +func (s *service) getReporter(target quota.TargetSrv) (quota.UsageReporterFunc, bool) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + r, ok := s.reporters[target] + return r, ok +} + +type reporter struct { + target quota.TargetSrv + reporterFunc quota.UsageReporterFunc +} + +func (s *service) getReporters() <-chan reporter { + ch := make(chan reporter) + go func() { + s.mutex.RLock() + defer func() { + s.mutex.RUnlock() + close(ch) + }() + for t, r := range s.reporters { + ch <- reporter{target: t, reporterFunc: r} + } + }() + + return ch +} + +func (s *service) getOverridenLimits(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (map[quota.Tag]int64, error) { + targetSrvLimits := make(map[quota.Tag]int64) + + c, err := s.getContext(ctx) + if err != nil { + return nil, err + } + customLimits, err := s.store.Get(c, scopeParams) + if err != nil { + return targetSrvLimits, err + } + + for item := range s.defaultLimits.Iter() { + srv, err := item.Tag.GetSrv() + if err != nil { + return nil, err + } + + if srv != targetSrv { + continue + } + + defaultLimit := item.Value + + if customLimit, ok := customLimits.Get(item.Tag); ok { + targetSrvLimits[item.Tag] = customLimit + } else { + targetSrvLimits[item.Tag] = defaultLimit + } + } + + return targetSrvLimits, nil +} + +func (s *service) getUsage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + usage := "a.Map{} + g, ctx := errgroup.WithContext(ctx) + + for r := range s.getReporters() { + r := r + g.Go(func() error { + u, err := r.reporterFunc(ctx, scopeParams) + if err != nil { + return err + } + usage.Merge(u) + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + return usage, nil +} + +func (s *service) getContext(ctx context.Context) (quota.Context, error) { + return quota.FromContext(ctx, s.targetToSrv), nil } diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index c2cdfd5edda..17164adc785 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -3,26 +3,481 @@ package quotaimpl import ( "context" "testing" + "time" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/annotations/annotationstest" + "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" + "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/dashboards" + dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/datasources" + dsservice "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/ngalert" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + ngalerttests "github.com/grafana/grafana/pkg/services/ngalert/tests" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" + secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" + storesrv "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "github.com/xorcare/pointer" ) func TestQuotaService(t *testing.T) { - quotaStore := &FakeQuotaStore{} - quotaService := Service{ + quotaStore := "atest.FakeQuotaStore{} + quotaService := service{ store: quotaStore, } t.Run("delete quota", func(t *testing.T) { - err := quotaService.DeleteByUser(context.Background(), 1) + err := quotaService.DeleteQuotaForUser(context.Background(), 1) require.NoError(t, err) }) } -type FakeQuotaStore struct { - ExpectedError error +func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + sqlStore := sqlstore.InitTestDB(t) + sqlStore.Cfg.Quota = setting.QuotaSettings{ + Enabled: true, + + Org: setting.OrgQuota{ + User: 2, + Dashboard: 3, + DataSource: 4, + ApiKey: 5, + AlertRule: 6, + }, + User: setting.UserQuota{ + Org: 7, + }, + Global: setting.GlobalQuota{ + Org: 8, + User: 9, + Dashboard: 10, + DataSource: 11, + ApiKey: 12, + Session: 13, + AlertRule: 14, + File: 15, + }, + } + + b := bus.ProvideBus(tracing.InitializeTracerForTest()) + quotaService := ProvideService(sqlStore, sqlStore.Cfg) + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + userService, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) + setupEnv(t, sqlStore, b, quotaService) + + u, err := userService.Create(context.Background(), &user.CreateUserCommand{ + Name: "TestUser", + SkipOrgSetup: true, + }) + require.NoError(t, err) + + o, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{ + Name: "TestOrg", + UserID: u.ID, + }) + require.NoError(t, err) + + // fetch global default limit/usage + defaultGlobalLimits := make(map[quota.Tag]int64) + existingGlobalUsage := make(map[quota.Tag]int64) + scope := quota.GlobalScope + result, err := quotaService.GetQuotasByScope(context.Background(), scope, 0) + require.NoError(t, err) + for _, r := range result { + tag, err := r.Tag() + require.NoError(t, err) + defaultGlobalLimits[tag] = r.Limit + existingGlobalUsage[tag] = r.Used + } + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.Org, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.User, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.Dashboard, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.DataSource, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.ApiKey, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.Session, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.AlertRule, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(storesrv.QuotaTargetSrv, storesrv.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.File, defaultGlobalLimits[tag]) + + // fetch default limit/usage for org + defaultOrgLimits := make(map[quota.Tag]int64) + existingOrgUsage := make(map[quota.Tag]int64) + scope = quota.OrgScope + result, err = quotaService.GetQuotasByScope(context.Background(), scope, o.ID) + require.NoError(t, err) + for _, r := range result { + tag, err := r.Tag() + require.NoError(t, err) + defaultOrgLimits[tag] = r.Limit + existingOrgUsage[tag] = r.Used + } + tag, err = quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.User, defaultOrgLimits[tag]) + tag, err = quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.Dashboard, defaultOrgLimits[tag]) + tag, err = quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.DataSource, defaultOrgLimits[tag]) + tag, err = quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.ApiKey, defaultOrgLimits[tag]) + tag, err = quota.NewTag(ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.AlertRule, defaultOrgLimits[tag]) + + // fetch default limit/usage for user + defaultUserLimits := make(map[quota.Tag]int64) + existingUserUsage := make(map[quota.Tag]int64) + scope = quota.UserScope + result, err = quotaService.GetQuotasByScope(context.Background(), scope, u.ID) + require.NoError(t, err) + for _, r := range result { + tag, err := r.Tag() + require.NoError(t, err) + defaultUserLimits[tag] = r.Limit + existingUserUsage[tag] = r.Used + } + tag, err = quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.User.Org, defaultUserLimits[tag]) + + t.Run("Given saved org quota for users", func(t *testing.T) { + // update quota for the created org and limit users to 1 + var customOrgUserLimit int64 = 1 + orgCmd := quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: org.OrgUserQuotaTarget, + Limit: customOrgUserLimit, + } + err := quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved limit/usage for org users", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + + require.Equal(t, customOrgUserLimit, q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to get default org users limit/usage for unknown org", func(t *testing.T) { + unknownOrgID := -1 + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: int64(unknownOrgID)}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultOrgLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + t.Run("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { + // disable Grafana Alerting + cfg := *sqlStore.Cfg + cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{Enabled: pointer.Bool(false)} + + quotaSrv := ProvideService(sqlStore, &cfg) + q, err := getQuotaBySrvTargetScope(t, quotaSrv, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + + require.NoError(t, err) + require.Equal(t, int64(0), q.Limit) + }) + + t.Run("Should be able to quota list for org", func(t *testing.T) { + result, err := quotaService.GetQuotasByScope(context.Background(), quota.OrgScope, o.ID) + require.NoError(t, err) + require.Len(t, result, 5) + + require.NoError(t, err) + for _, res := range result { + tag, err := res.Tag() + require.NoError(t, err) + limit := defaultOrgLimits[tag] + used := existingOrgUsage[tag] + if res.Target == org.OrgUserQuotaTarget { + limit = customOrgUserLimit + used = 1 // one user in the created org + } + require.Equal(t, limit, res.Limit) + require.Equal(t, used, res.Used) + } + }) + }) + + t.Run("Given saved org quota for dashboards", func(t *testing.T) { + // update quota for the created org and limit dashboards to 1 + var customOrgDashboardLimit int64 = 1 + orgCmd := quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: string(dashboards.QuotaTarget), + Limit: customOrgDashboardLimit, + } + err := quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, customOrgDashboardLimit, q.Limit) + require.Equal(t, existingOrgUsage[tag], q.Used) + }) + }) + + t.Run("Given saved user quota for org", func(t *testing.T) { + // update quota for the created user and limit orgs to 1 + var customUserOrgsLimit int64 = 1 + userQuotaCmd := quota.UpdateQuotaCmd{ + UserID: u.ID, + Target: org.OrgUserQuotaTarget, + Limit: customUserOrgsLimit, + } + err := quotaService.Update(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved limit/usage for user orgs", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) + require.NoError(t, err) + + require.Equal(t, customUserOrgsLimit, q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to get default user orgs limit/usage for unknown user", func(t *testing.T) { + var unknownUserID int64 = -1 + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: unknownUserID}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultUserLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + t.Run("Should be able to quota list for user", func(t *testing.T) { + result, err = quotaService.GetQuotasByScope(context.Background(), quota.UserScope, u.ID) + require.NoError(t, err) + require.Len(t, result, 1) + for _, res := range result { + tag, err := res.Tag() + require.NoError(t, err) + limit := defaultUserLimits[tag] + used := existingUserUsage[tag] + if res.Target == org.OrgUserQuotaTarget { + limit = customUserOrgsLimit // customized quota limit. + used = 1 // one user in the created org + } + require.Equal(t, limit, res.Limit) + require.Equal(t, used, res.Used) + } + }) + }) + + t.Run("Should be able to global user quota", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to global org quota", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + t.Run("Should be able to global dashboard quota", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + t.Run("Should org quota updating is successful even if it called multiple time", func(t *testing.T) { + // update quota for the created org and limit users to 1 + var customOrgUserLimit int64 = 1 + orgCmd := quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: org.OrgUserQuotaTarget, + Limit: customOrgUserLimit, + } + err := quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + query, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + require.Equal(t, customOrgUserLimit, query.Limit) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + customOrgUserLimit = 2 + orgCmd = quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: org.OrgUserQuotaTarget, + Limit: customOrgUserLimit, + } + err = quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + query, err = getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + require.Equal(t, customOrgUserLimit, query.Limit) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + t.Run("Should user quota updating is successful even if it called multiple time", func(t *testing.T) { + // update quota for the created org and limit users to 1 + var customUserOrgLimit int64 = 1 + userQuotaCmd := quota.UpdateQuotaCmd{ + UserID: u.ID, + Target: org.OrgUserQuotaTarget, + Limit: customUserOrgLimit, + } + err := quotaService.Update(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + query, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) + require.NoError(t, err) + require.Equal(t, customUserOrgLimit, query.Limit) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + customUserOrgLimit = 10 + userQuotaCmd = quota.UpdateQuotaCmd{ + UserID: u.ID, + Target: org.OrgUserQuotaTarget, + Limit: customUserOrgLimit, + } + err = quotaService.Update(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + query, err = getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) + require.NoError(t, err) + require.Equal(t, customUserOrgLimit, query.Limit) + }) + + // TODO data_source, file } -func (f *FakeQuotaStore) DeleteByUser(ctx context.Context, userID int64) error { - return f.ExpectedError +func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quota.TargetSrv, target quota.Target, scope quota.Scope, scopeParams *quota.ScopeParameters) (quota.QuotaDTO, error) { + t.Helper() + + var id int64 = 0 + switch { + case scope == quota.OrgScope: + id = scopeParams.OrgID + case scope == quota.UserScope: + id = scopeParams.UserID + } + + result, err := quotaService.GetQuotasByScope(context.Background(), scope, id) + require.NoError(t, err) + for _, r := range result { + if r.Target != string(target) { + continue + } + + if r.Service != string(srv) { + continue + } + + if r.Scope != string(scope) { + continue + } + + require.Equal(t, r.OrgId, scopeParams.OrgID) + require.Equal(t, r.UserId, scopeParams.UserID) + return r, nil + } + return quota.QuotaDTO{}, err +} + +func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService quota.Service) { + _, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + _, err = auth.ProvideActiveAuthTokenService(sqlStore.Cfg, sqlStore, quotaService) + require.NoError(t, err) + _, err = dashboardStore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) + secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) + secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) + _, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New().WithDisabled(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) + m := metrics.NewNGAlert(prometheus.NewRegistry()) + _, err = ngalert.ProvideService( + sqlStore.Cfg, &ngalerttests.FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotaService, + secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, annotationstest.NewFakeAnnotationsRepo(), + ) + require.NoError(t, err) + _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService) + require.NoError(t, err) } diff --git a/pkg/services/quota/quotaimpl/store.go b/pkg/services/quota/quotaimpl/store.go index 6b3a32bdb91..d6111580f28 100644 --- a/pkg/services/quota/quotaimpl/store.go +++ b/pkg/services/quota/quotaimpl/store.go @@ -1,23 +1,130 @@ package quotaimpl import ( - "context" + "time" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" ) type store interface { - DeleteByUser(context.Context, int64) error + Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) + Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error + DeleteByUser(quota.Context, int64) error } type sqlStore struct { - db db.DB + db db.DB + logger log.Logger } -func (ss *sqlStore) DeleteByUser(ctx context.Context, userID int64) error { +func (ss *sqlStore) DeleteByUser(ctx quota.Context, userID int64) error { return ss.db.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM quota WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err }) } + +func (ss *sqlStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + limits := quota.Map{} + if scopeParams.OrgID != 0 { + orgLimits, err := ss.getOrgScopeQuota(ctx, scopeParams.OrgID) + if err != nil { + return nil, err + } + limits.Merge(orgLimits) + } + + if scopeParams.UserID != 0 { + userLimits, err := ss.getUserScopeQuota(ctx, scopeParams.UserID) + if err != nil { + return nil, err + } + limits.Merge(userLimits) + } + + return &limits, nil +} + +func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // Check if quota is already defined in the DB + quota := quota.Quota{ + Target: cmd.Target, + UserId: cmd.UserID, + OrgId: cmd.OrgID, + } + has, err := sess.Get("a) + if err != nil { + return err + } + quota.Updated = time.Now() + quota.Limit = cmd.Limit + if !has { + quota.Created = time.Now() + // No quota in the DB for this target, so create a new one. + if _, err := sess.Insert("a); err != nil { + return err + } + } else { + // update existing quota entry in the DB. + _, err := sess.ID(quota.Id).Update("a) + if err != nil { + return err + } + } + + return nil + }) +} + +func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.Map, error) { + r := quota.Map{} + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + quotas := make([]*quota.Quota, 0) + if err := sess.Table("quota").Where("user_id=? AND org_id=0", userID).Find("as); err != nil { + return err + } + + for _, q := range quotas { + srv, ok := ctx.TargetToSrv.Get(quota.Target(q.Target)) + if !ok { + ss.logger.Info("failed to get service for target", "target", q.Target) + } + tag, err := quota.NewTag(srv, quota.Target(q.Target), quota.UserScope) + if err != nil { + return err + } + r.Set(tag, q.Limit) + } + return nil + }) + return &r, err +} + +func (ss *sqlStore) getOrgScopeQuota(ctx quota.Context, OrgID int64) (*quota.Map, error) { + r := quota.Map{} + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + quotas := make([]*quota.Quota, 0) + if err := sess.Table("quota").Where("user_id=0 AND org_id=?", OrgID).Find("as); err != nil { + return err + } + + for _, q := range quotas { + srv, ok := ctx.TargetToSrv.Get(quota.Target(q.Target)) + if !ok { + ss.logger.Info("failed to get service for target", "target", q.Target) + } + tag, err := quota.NewTag(srv, quota.Target(q.Target), quota.OrgScope) + if err != nil { + return err + } + r.Set(tag, q.Limit) + } + return nil + }) + return &r, err +} diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index f9f7a184456..d332ab97851 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/quota" ) func TestIntegrationQuotaDataAccess(t *testing.T) { @@ -20,7 +21,8 @@ func TestIntegrationQuotaDataAccess(t *testing.T) { } t.Run("quota deleted", func(t *testing.T) { - err := quotaStore.DeleteByUser(context.Background(), 1) + ctx := quota.FromContext(context.Background(), "a.TargetToSrv{}) + err := quotaStore.DeleteByUser(ctx, 1) require.NoError(t, err) }) } diff --git a/pkg/services/quota/quotatest/fake.go b/pkg/services/quota/quotatest/fake.go index 00eae845789..d62267d9276 100644 --- a/pkg/services/quota/quotatest/fake.go +++ b/pkg/services/quota/quotatest/fake.go @@ -12,18 +12,46 @@ type FakeQuotaService struct { err error } -func NewQuotaServiceFake() *FakeQuotaService { - return &FakeQuotaService{} +func New(reached bool, err error) *FakeQuotaService { + return &FakeQuotaService{reached, err} } -func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target string) (bool, error) { +func (f *FakeQuotaService) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { + return []quota.QuotaDTO{}, nil +} + +func (f *FakeQuotaService) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { + return nil +} + +func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return f.reached, f.err } -func (f *FakeQuotaService) CheckQuotaReached(c context.Context, target string, params *quota.ScopeParameters) (bool, error) { +func (f *FakeQuotaService) CheckQuotaReached(c context.Context, target quota.TargetSrv, params *quota.ScopeParameters) (bool, error) { return f.reached, f.err } -func (f *FakeQuotaService) DeleteByUser(c context.Context, userID int64) error { +func (f *FakeQuotaService) DeleteQuotaForUser(c context.Context, userID int64) error { return f.err } + +func (f *FakeQuotaService) RegisterQuotaReporter(e *quota.NewUsageReporter) error { + return f.err +} + +type FakeQuotaStore struct { + ExpectedError error +} + +func (f *FakeQuotaStore) DeleteByUser(ctx quota.Context, userID int64) error { + return f.ExpectedError +} + +func (f *FakeQuotaStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return nil, f.ExpectedError +} + +func (f *FakeQuotaStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { + return f.ExpectedError +} diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index a8e8ef8bcaa..ded9d12412f 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" dsservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -27,7 +29,9 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS features = featuremgmt.WithFeatures(featuremgmt.FlagDisableSecretsCompatibility, true) } secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) migService := ProvideDataSourceMigrationService(dsService, kvStore, features) return migService } diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 0e87ed07c82..8abeeb43789 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" @@ -44,9 +45,12 @@ var ( func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) - orgService := orgimpl.ProvideService(store, setting.NewCfg()) + orgService, err := orgimpl.ProvideService(store, setting.NewCfg(), quotaService) + require.NoError(t, err) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, orgService) svcmock := tests.ServiceAccountMock{} @@ -57,7 +61,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { }() orgCmd := &models.CreateOrgCommand{Name: "Some Test Org"} - err := store.CreateOrg(context.Background(), orgCmd) + err = store.CreateOrg(context.Background(), orgCmd) require.Nil(t, err) type testCreateSATestCase struct { @@ -212,7 +216,9 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { store := db.InitTestDB(t) kvStore := kvstore.ProvideService(store) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -284,7 +290,9 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, sqlStore db.DB, saStore serviceaccounts.Store) (*web.Mux, *ServiceAccountsAPI) { cfg := setting.NewCfg() teamSvc := teamimpl.ProvideService(sqlStore, cfg) - userSvc := userimpl.ProvideService(sqlStore, nil, cfg, teamimpl.ProvideService(sqlStore, cfg), nil) + + userSvc, err := userimpl.ProvideService(sqlStore, nil, cfg, teamimpl.ProvideService(sqlStore, cfg), nil, quotatest.New(false, nil)) + require.NoError(t, err) saPermissionService, err := ossaccesscontrol.ProvideServiceAccountPermissions( cfg, routing.NewRouteRegister(), sqlStore, acmock, &licensing.OSSLicensingService{}, saStore, acmock, teamSvc, userSvc) require.NoError(t, err) @@ -316,7 +324,9 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -408,7 +418,9 @@ func newString(s string) *string { func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 9e9e91f4d98..90b234d24d2 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -23,6 +23,7 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" @@ -54,7 +55,9 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, func TestServiceAccountsAPI_CreateToken(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -171,7 +174,9 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { func TestServiceAccountsAPI_DeleteToken(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) svcMock := &tests.ServiceAccountMock{} saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index a6aba5f1367..be9011ed9bc 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -112,9 +113,12 @@ func TestStore_DeleteServiceAccount(t *testing.T) { func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreImpl) { t.Helper() db := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(db, db.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(db, db.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(db) - orgService := orgimpl.ProvideService(db, setting.NewCfg()) + orgService, err := orgimpl.ProvideService(db, setting.NewCfg(), quotaService) + require.NoError(t, err) return db, ProvideServiceAccountsStore(db, apiKeyService, kvStore, orgService) } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 2671cc065e3..d8b5dea247b 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" @@ -70,8 +71,10 @@ func SetupApiKey(t *testing.T, sqlStore *sqlstore.SQLStore, testKey TestApiKey) addKeyCmd.Key = "secret" } - apiKeyService := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg) - err := apiKeyService.AddAPIKey(context.Background(), addKeyCmd) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + err = apiKeyService.AddAPIKey(context.Background(), addKeyCmd) require.NoError(t, err) if testKey.IsExpired { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index bf23c79e8a2..1b4757b2e93 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -98,34 +98,6 @@ func (m *SQLStoreMock) WithNewDbSession(ctx context.Context, callback sqlstore.D return m.ExpectedError } -func (m *SQLStoreMock) GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error { - return m.ExpectedError -} - func (m *SQLStoreMock) WithTransactionalDbSession(ctx context.Context, callback sqlstore.DBTransactionFunc) error { return m.ExpectedError } diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go deleted file mode 100644 index a28dba881d7..00000000000 --- a/pkg/services/sqlstore/quota.go +++ /dev/null @@ -1,315 +0,0 @@ -package sqlstore - -import ( - "context" - "fmt" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" -) - -const ( - alertRuleTarget = "alert_rule" - dashboardTarget = "dashboard" - filesTarget = "file" -) - -type targetCount struct { - Count int64 -} - -func (ss *SQLStore) GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quota := models.Quota{ - Target: query.Target, - OrgId: query.OrgId, - } - has, err := sess.Get("a) - if err != nil { - return err - } else if !has { - quota.Limit = query.Default - } - - var used int64 - if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s WHERE org_id=?", - dialect.Quote(query.Target)) - - if query.Target == dashboardTarget { - rawSQL += fmt.Sprintf(" AND is_folder=%s", dialect.BooleanStr(false)) - } - // need to account for removing service accounts from the user table - if query.Target == "org_user" { - rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from (select user_id from %s where org_id=? AND user_id IN (SELECT id as user_id FROM %s WHERE is_service_account=%s)) as subq", - dialect.Quote(query.Target), - dialect.Quote("user"), - dialect.BooleanStr(false), - ) - } - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, query.OrgId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - - query.Result = &models.OrgQuotaDTO{ - Target: query.Target, - Limit: quota.Limit, - OrgId: query.OrgId, - Used: used, - } - - return nil - }) -} - -func (ss *SQLStore) GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quotas := make([]*models.Quota, 0) - if err := sess.Table("quota").Where("org_id=? AND user_id=0", query.OrgId).Find("as); err != nil { - return err - } - - defaultQuotas := setting.Quota.Org.ToMap() - - seenTargets := make(map[string]bool) - for _, q := range quotas { - seenTargets[q.Target] = true - } - - for t, v := range defaultQuotas { - if _, ok := seenTargets[t]; !ok { - quotas = append(quotas, &models.Quota{ - OrgId: query.OrgId, - Target: t, - Limit: v, - }) - } - } - - result := make([]*models.OrgQuotaDTO, len(quotas)) - for i, q := range quotas { - var used int64 - var rawSQL string - if q.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(q.Target)) - - // need to account for removing service accounts from the user table - if q.Target == "org_user" { - rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from (select user_id from %s where org_id=? AND user_id IN (SELECT id as user_id FROM %s WHERE is_service_account=%s)) as subq", - dialect.Quote(q.Target), - dialect.Quote("user"), - dialect.BooleanStr(false), - ) - } - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, q.OrgId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - result[i] = &models.OrgQuotaDTO{ - Target: q.Target, - Limit: q.Limit, - OrgId: q.OrgId, - Used: used, - } - } - query.Result = result - return nil - }) -} - -func (ss *SQLStore) UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - // Check if quota is already defined in the DB - quota := models.Quota{ - Target: cmd.Target, - OrgId: cmd.OrgId, - } - has, err := sess.Get("a) - if err != nil { - return err - } - quota.Updated = time.Now() - quota.Limit = cmd.Limit - if !has { - quota.Created = time.Now() - // No quota in the DB for this target, so create a new one. - if _, err := sess.Insert("a); err != nil { - return err - } - } else { - // update existing quota entry in the DB. - _, err := sess.ID(quota.Id).Update("a) - if err != nil { - return err - } - } - - return nil - }) -} - -func (ss *SQLStore) GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quota := models.Quota{ - Target: query.Target, - UserId: query.UserId, - } - has, err := sess.Get("a) - if err != nil { - return err - } else if !has { - quota.Limit = query.Default - } - - var used int64 - if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(query.Target)) - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, query.UserId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - - query.Result = &models.UserQuotaDTO{ - Target: query.Target, - Limit: quota.Limit, - UserId: query.UserId, - Used: used, - } - - return nil - }) -} - -func (ss *SQLStore) GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quotas := make([]*models.Quota, 0) - if err := sess.Table("quota").Where("user_id=? AND org_id=0", query.UserId).Find("as); err != nil { - return err - } - - defaultQuotas := setting.Quota.User.ToMap() - - seenTargets := make(map[string]bool) - for _, q := range quotas { - seenTargets[q.Target] = true - } - - for t, v := range defaultQuotas { - if _, ok := seenTargets[t]; !ok { - quotas = append(quotas, &models.Quota{ - UserId: query.UserId, - Target: t, - Limit: v, - }) - } - } - - result := make([]*models.UserQuotaDTO, len(quotas)) - for i, q := range quotas { - var used int64 - if q.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(q.Target)) - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, q.UserId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - result[i] = &models.UserQuotaDTO{ - Target: q.Target, - Limit: q.Limit, - UserId: q.UserId, - Used: used, - } - } - query.Result = result - return nil - }) -} - -func (ss *SQLStore) UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - // Check if quota is already defined in the DB - quota := models.Quota{ - Target: cmd.Target, - UserId: cmd.UserId, - } - has, err := sess.Get("a) - if err != nil { - return err - } - quota.Updated = time.Now() - quota.Limit = cmd.Limit - if !has { - quota.Created = time.Now() - // No quota in the DB for this target, so create a new one. - if _, err := sess.Insert("a); err != nil { - return err - } - } else { - // update existing quota entry in the DB. - _, err := sess.ID(quota.Id).Update("a) - if err != nil { - return err - } - } - - return nil - }) -} - -func (ss *SQLStore) GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - var used int64 - - if query.Target == filesTarget { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", - dialect.Quote("file")) - - notFolderCondition := fmt.Sprintf(" WHERE path NOT LIKE '%s'", "%/") - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL + notFolderCondition).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } else if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", - dialect.Quote(query.Target)) - - if query.Target == dashboardTarget { - rawSQL += fmt.Sprintf(" WHERE is_folder=%s", dialect.BooleanStr(false)) - } - // removing service accounts from count - if query.Target == dialect.Quote("user") { - rawSQL += fmt.Sprintf(" WHERE is_service_account=%s", dialect.BooleanStr(false)) - } - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - - query.Result = &models.GlobalQuotaDTO{ - Target: query.Target, - Limit: query.Default, - Used: used, - } - - return nil - }) -} diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go deleted file mode 100644 index e58b42adf8d..00000000000 --- a/pkg/services/sqlstore/quota_test.go +++ /dev/null @@ -1,301 +0,0 @@ -package sqlstore - -import ( - "context" - "testing" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" -) - -func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - sqlStore := InitTestDB(t) - userId := int64(1) - orgId := int64(0) - - setting.Quota = setting.QuotaSettings{ - Enabled: true, - Org: &setting.OrgQuota{ - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - AlertRule: 5, - }, - User: &setting.UserQuota{ - Org: 5, - }, - Global: &setting.GlobalQuota{ - Org: 5, - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - Session: 5, - AlertRule: 5, - }, - } - createUserCmd := user.CreateUserCommand{ - Name: "TestUser", - OrgID: orgId, - SkipOrgSetup: true, - } - user, err := sqlStore.CreateUser(context.Background(), createUserCmd) - require.NoError(t, err) - // create a new org and add user_id 1 as admin. - // we will then have an org with 1 user. and a user - // with 1 org. - userCmd := models.CreateOrgCommand{ - Name: "TestOrg", - UserId: user.ID, - } - - err = sqlStore.CreateOrg(context.Background(), &userCmd) - require.NoError(t, err) - orgId = userCmd.Result.Id - - t.Run("Given saved org quota for users", func(t *testing.T) { - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: "org_user", - Limit: 10, - } - err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) - - t.Run("Should be able to get default quota by org id and target", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: 123, Target: "org_user", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(11), query.Result.Limit) - }) - - t.Run("Should be able to get used org quota when rows exist", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to get used org quota when no rows exist", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "org_user", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "alert", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to quota list for org", func(t *testing.T) { - query := models.GetOrgQuotasQuery{OrgId: orgId} - err = sqlStore.GetOrgQuotas(context.Background(), &query) - - require.NoError(t, err) - require.Len(t, query.Result, 5) - for _, res := range query.Result { - limit := int64(5) // default quota limit - used := int64(0) - if res.Target == "org_user" { - limit = 10 // customized quota limit. - used = 1 - } - require.Equal(t, limit, res.Limit) - require.Equal(t, used, res.Used) - } - }) - }) - - t.Run("Given saved org quota for dashboards", func(t *testing.T) { - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: dashboardTarget, - Limit: 10, - } - err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: dashboardTarget, Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - require.Equal(t, int64(0), query.Result.Used) - }) - }) - - t.Run("Given saved user quota for org", func(t *testing.T) { - userQuotaCmd := models.UpdateUserQuotaCmd{ - UserId: userId, - Target: "org_user", - Limit: 10, - } - err := sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by user id and target", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) - - t.Run("Should be able to get default quota by user id and target", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: 9, Target: "org_user", Default: 11} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(11), query.Result.Limit) - }) - - t.Run("Should be able to get used user quota when rows exist", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 11} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to get used user quota when no rows exist", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: 2, Target: "org_user", Default: 11} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to quota list for user", func(t *testing.T) { - query := models.GetUserQuotasQuery{UserId: userId} - err = sqlStore.GetUserQuotas(context.Background(), &query) - - require.NoError(t, err) - require.Len(t, query.Result, 1) - require.Equal(t, int64(10), query.Result[0].Limit) - require.Equal(t, int64(1), query.Result[0].Used) - }) - }) - - t.Run("Should be able to global user quota", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: "user", Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to global org quota", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: "org", Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: "alert_rule", Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to global dashboard quota", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: dashboardTarget, Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(0), query.Result.Used) - }) - - // related: https://github.com/grafana/grafana/issues/14342 - t.Run("Should org quota updating is successful even if it called multiple time", func(t *testing.T) { - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: "org_user", - Limit: 5, - } - err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(5), query.Result.Limit) - - // XXX: resolution of `Updated` column is 1sec, so this makes delay - time.Sleep(1 * time.Second) - - orgCmd = models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: "org_user", - Limit: 10, - } - err = sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - query = models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) - - // related: https://github.com/grafana/grafana/issues/14342 - t.Run("Should user quota updating is successful even if it called multiple time", func(t *testing.T) { - userQuotaCmd := models.UpdateUserQuotaCmd{ - UserId: userId, - Target: "org_user", - Limit: 5, - } - err := sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(5), query.Result.Limit) - - // XXX: resolution of `Updated` column is 1sec, so this makes delay - time.Sleep(1 * time.Second) - - userQuotaCmd = models.UpdateUserQuotaCmd{ - UserId: userId, - Target: "org_user", - Limit: 10, - } - err = sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - query = models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) -} diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 463ad05b919..16ca3e885aa 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -23,13 +23,6 @@ type Store interface { GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error WithDbSession(ctx context.Context, callback DBTransactionFunc) error WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error - GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error - GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error - UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error - GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error - GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error - UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error - GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error WithTransactionalDbSession(ctx context.Context, callback DBTransactionFunc) error InTransaction(ctx context.Context, fn func(ctx context.Context) error) error Migrate(bool) error diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index 61eb97c4239..cc3beab7d10 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -58,6 +59,11 @@ type CreateFolderCmd struct { Path string `json:"path"` } +const ( + QuotaTargetSrv quota.TargetSrv = "store" + QuotaTarget quota.Target = "file" +) + type StorageService interface { registry.BackgroundService @@ -97,7 +103,7 @@ func ProvideService( features featuremgmt.FeatureToggles, cfg *setting.Cfg, quotaService quota.Service, -) StorageService { +) (StorageService, error) { settings, err := LoadStorageConfig(cfg, features) if err != nil { grafanaStorageLogger.Warn("error loading storage config", "error", err) @@ -259,7 +265,37 @@ func ProvideService( s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg) s.quotaService = quotaService s.cfg = settings - return s + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return nil, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return nil, err + } + + return s, nil +} + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.File) + return limits, nil } func createSystemBrandingPathFilter() filestorage.PathFilter { @@ -329,6 +365,32 @@ func (s *standardStorageService) Read(ctx context.Context, user *user.SignedInUs return s.tree.GetFile(ctx, getOrgId(user), path) } +func (s *standardStorageService) Usage(ctx context.Context, ScopeParameters *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + + err := s.sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + type result struct { + Count int64 + } + r := result{} + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM file WHERE path NOT LIKE '%s'", "%/") + + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return err + } + u.Set(tag, r.Count) + + return nil + }) + + return u, err +} + type UploadRequest struct { Contents []byte Path string @@ -395,7 +457,7 @@ func (s *standardStorageService) Upload(ctx context.Context, user *user.SignedIn func (s *standardStorageService) checkFileQuota(ctx context.Context, path string) error { // assumes we are only uploading to the SQL database - TODO: refactor once we introduce object stores - quotaReached, err := s.quotaService.CheckQuotaReached(ctx, "file", nil) + quotaReached, err := s.quotaService.CheckQuotaReached(ctx, QuotaTargetSrv, nil) if err != nil { grafanaStorageLogger.Error("failed while checking upload quota", "path", path, "error", err) return ErrUploadInternalError diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 650c3dceefc..c74b744af16 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -118,7 +118,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } - store.quotaService = quotatest.NewQuotaServiceFake() + store.quotaService = quotatest.New(false, nil) return store, mockStorage, storageName } @@ -297,7 +297,7 @@ func TestContentRootWithNestedStorage(t *testing.T) { store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } - store.quotaService = quotatest.NewQuotaServiceFake() + store.quotaService = quotatest.New(false, nil) fileName := "file.jpg" tests := []struct { diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index b5d66f1b360..88951c1cc5a 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -357,3 +357,8 @@ type SearchUserFilter interface { } type FilterHandler func(params []string) (Filter, error) + +const ( + QuotaTargetSrv string = "user" + QuotaTarget string = "user" +) diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index c368be1389c..53deaf17c41 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -37,6 +38,8 @@ type store interface { BatchDisableUsers(context.Context, *user.BatchDisableUsersCommand) error Disable(context.Context, *user.DisableUserCommand) error Search(context.Context, *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) + + Count(ctx context.Context) (int64, error) } type sqlStore struct { @@ -461,6 +464,22 @@ func (ss *sqlStore) UpdatePermissions(ctx context.Context, userID int64, isAdmin }) } +func (ss *sqlStore) Count(ctx context.Context) (int64, error) { + type result struct { + Count int64 + } + + r := result{} + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s WHERE is_service_account=%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().BooleanStr(false)) + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }) + return r.Count, err +} + // validateOneAdminLeft validate that there is an admin user left func validateOneAdminLeft(ctx context.Context, sess *db.Session) error { count, err := sess.Where("is_admin=?", true).Count(&user.User{}) diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index 96dab700d94..f2250a5245d 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -32,15 +33,44 @@ func ProvideService( cfg *setting.Cfg, teamService team.Service, cacheService *localcache.CacheService, -) user.Service { + quotaService quota.Service, +) (user.Service, error) { store := ProvideStore(db, cfg) - return &Service{ + s := &Service{ store: &store, orgService: orgService, cfg: cfg, teamService: teamService, cacheService: cacheService, } + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: quota.TargetSrv(user.QuotaTargetSrv), + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return s, err + } + return s, nil +} + +func (s *Service) Usage(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + if used, err := s.store.Count(ctx); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, used) + } + return u, nil } func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { @@ -304,3 +334,19 @@ func (s *Service) GetProfile(ctx context.Context, query *user.GetUserProfileQuer result, err := s.store.GetProfile(ctx, query) return result, err } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.User) + return limits, nil +} diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index a371c74789d..aadd510e2ad 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -252,3 +252,7 @@ func (f *FakeUserStore) Disable(ctx context.Context, cmd *user.DisableUserComman func (f *FakeUserStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { return f.ExpectedSearchUserQueryResult, f.ExpectedError } + +func (f *FakeUserStore) Count(ctx context.Context) (int64, error) { + return 0, nil +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index cf48a800f44..44a79754cfd 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -153,9 +153,6 @@ var ( LDAPAllowSignup bool LDAPActiveSyncEnabled bool - // Quota - Quota QuotaSettings - // Alerting AlertingEnabled *bool ExecuteAlerts bool @@ -422,12 +419,12 @@ type Cfg struct { LDAPSkipOrgRoleSync bool LDAPAllowSignup bool - Quota QuotaSettings - DefaultTheme string DefaultLocale string HomePage string + Quota QuotaSettings + AutoAssignOrg bool AutoAssignOrgId int AutoAssignOrgRole string @@ -1053,11 +1050,12 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.readAzureSettings() cfg.readSessionConfig() cfg.readSmtpSettings() - cfg.readQuotaSettings() if err := cfg.readAnnotationSettings(); err != nil { return err } + cfg.readQuotaSettings() + cfg.readExpressionsSettings() if err := cfg.readGrafanaEnvironmentMetrics(); err != nil { return err diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go index b3cd6d01115..053adb74662 100644 --- a/pkg/setting/setting_quota.go +++ b/pkg/setting/setting_quota.go @@ -1,9 +1,5 @@ package setting -import ( - "reflect" -) - type OrgQuota struct { User int64 `target:"org_user"` DataSource int64 `target:"data_source"` @@ -27,45 +23,17 @@ type GlobalQuota struct { File int64 `target:"file"` } -func (q *OrgQuota) ToMap() map[string]int64 { - return quotaToMap(*q) -} - -func (q *UserQuota) ToMap() map[string]int64 { - return quotaToMap(*q) -} - -func quotaToMap(q interface{}) map[string]int64 { - qMap := make(map[string]int64) - typ := reflect.TypeOf(q) - val := reflect.ValueOf(q) - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - name := field.Tag.Get("target") - if name == "" { - name = field.Name - } - if name == "-" { - continue - } - value := val.Field(i) - qMap[name] = value.Int() - } - return qMap -} - type QuotaSettings struct { Enabled bool - Org *OrgQuota - User *UserQuota - Global *GlobalQuota + Org OrgQuota + User UserQuota + Global GlobalQuota } func (cfg *Cfg) readQuotaSettings() { // set global defaults. quota := cfg.Raw.Section("quota") - Quota.Enabled = quota.Key("enabled").MustBool(false) + cfg.Quota.Enabled = quota.Key("enabled").MustBool(false) var alertOrgQuota int64 var alertGlobalQuota int64 @@ -74,7 +42,7 @@ func (cfg *Cfg) readQuotaSettings() { alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1) } // per ORG Limits - Quota.Org = &OrgQuota{ + cfg.Quota.Org = OrgQuota{ User: quota.Key("org_user").MustInt64(10), DataSource: quota.Key("org_data_source").MustInt64(10), Dashboard: quota.Key("org_dashboard").MustInt64(10), @@ -83,12 +51,12 @@ func (cfg *Cfg) readQuotaSettings() { } // per User limits - Quota.User = &UserQuota{ + cfg.Quota.User = UserQuota{ Org: quota.Key("user_org").MustInt64(10), } // Global Limits - Quota.Global = &GlobalQuota{ + cfg.Quota.Global = GlobalQuota{ User: quota.Key("global_user").MustInt64(-1), Org: quota.Key("global_org").MustInt64(-1), DataSource: quota.Key("global_data_source").MustInt64(-1), @@ -98,6 +66,4 @@ func (cfg *Cfg) readQuotaSettings() { File: quota.Key("global_file").MustInt64(-1), AlertRule: alertGlobalQuota, } - - cfg.Quota = Quota } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index aaf7afb0693..5755d9b6497 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -1878,6 +1877,8 @@ func TestQuota(t *testing.T) { // Create a user to make authenticated requests createUser(t, store, user.CreateUserCommand{ + // needs permission to update org quota + IsAdmin: true, DefaultOrgRole: string(org.RoleEditor), Password: "password", Login: "grafana", @@ -1918,30 +1919,10 @@ func TestQuota(t *testing.T) { // check quota limits t.Run("when quota limit exceed creating new rule should fail", func(t *testing.T) { // get existing org quota - query := models.GetOrgQuotaByTargetQuery{OrgId: 1, Target: "alert_rule"} - err = store.GetOrgQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - used := query.Result.Used - limit := query.Result.Limit - - // set org quota limit to equal used - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: 1, - Target: "alert_rule", - Limit: used, - } - err := store.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - + limit, used := apiClient.GetOrgQuotaLimits(t, 1) + apiClient.UpdateAlertRuleOrgQuota(t, 1, used) t.Cleanup(func() { - // reset org quota to original value - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: 1, - Target: "alert_rule", - Limit: limit, - } - err := store.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) + apiClient.UpdateAlertRuleOrgQuota(t, 1, limit) }) // try to create an alert rule diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 0b2540a7628..f13443b5d2d 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -16,6 +16,7 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util" ) @@ -202,6 +203,60 @@ func (a apiClient) CreateFolder(t *testing.T, uID string, title string) { a.ReloadCachedPermissions(t) } +func (a apiClient) GetOrgQuotaLimits(t *testing.T, orgID int64) (int64, int64) { + t.Helper() + + u := fmt.Sprintf("%s/api/orgs/%d/quotas", a.url, orgID) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + results := []quota.QuotaDTO{} + require.NoError(t, json.Unmarshal(b, &results)) + + var limit int64 = 0 + var used int64 = 0 + for _, q := range results { + if q.Target != string(ngmodels.QuotaTargetSrv) { + continue + } + limit = q.Limit + used = q.Used + } + return limit, used +} + +func (a apiClient) UpdateAlertRuleOrgQuota(t *testing.T, orgID int64, limit int64) { + t.Helper() + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode("a.UpdateQuotaCmd{ + Target: "alert_rule", + Limit: limit, + OrgID: orgID, + }) + require.NoError(t, err) + + u := fmt.Sprintf("%s/api/orgs/%d/quotas/alert_rule", a.url, orgID) + // nolint:gosec + client := &http.Client{} + req, err := http.NewRequest(http.MethodPut, u, &buf) + require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (int, string) { t.Helper() buf := bytes.Buffer{} diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index 263fc24a828..beee540e81b 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -16,16 +16,14 @@ import ( datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) func TestHandleRequest(t *testing.T) { - cfg := &setting.Cfg{} - t.Run("Should invoke plugin manager QueryData when handling request for query", func(t *testing.T) { origOAuthIsOAuthPassThruEnabledFunc := oAuthIsOAuthPassThruEnabledFunc oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *datasources.DataSource) bool { @@ -46,7 +44,10 @@ func TestHandleRequest(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) datasourcePermissions := acmock.NewMockedPermissionsService() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions, quotaService) + require.NoError(t, err) + s := ProvideService(client, nil, dsService) ds := &datasources.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} From 82d09e06473c1e754f18517bb6e0e76571420247 Mon Sep 17 00:00:00 2001 From: Shirley <4163034+fridgepoet@users.noreply.github.com> Date: Tue, 8 Nov 2022 10:28:50 +0100 Subject: [PATCH 104/926] CloudWatch: Refactor test mock by removing GetMetricsData from FakeMetricsAPI (#58355) --- .../get_metric_data_executor_test.go | 40 ++++------- .../cloudwatch/mocks/cloudwatch_metric_api.go | 13 +--- pkg/tsdb/cloudwatch/time_series_query_test.go | 70 +++++++++---------- 3 files changed, 49 insertions(+), 74 deletions(-) diff --git a/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go b/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go index dc9c3893077..4312fa4f364 100644 --- a/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go +++ b/pkg/tsdb/cloudwatch/get_metric_data_executor_test.go @@ -5,41 +5,27 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/cloudwatch" - "github.com/aws/aws-sdk-go/service/cloudwatch/cloudwatchiface" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -type cloudWatchFakeClient struct { - cloudwatchiface.CloudWatchAPI - - counterForGetMetricDataWithContext int -} - -func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { - nextToken := "next" - res := []*cloudwatch.MetricDataResult{{ - Values: []*float64{aws.Float64(12.3), aws.Float64(23.5)}, - }} - if client.counterForGetMetricDataWithContext == 0 { - nextToken = "" - res = []*cloudwatch.MetricDataResult{{ - Values: []*float64{aws.Float64(100)}, - }} - } - client.counterForGetMetricDataWithContext-- - return &cloudwatch.GetMetricDataOutput{ - MetricDataResults: res, - NextToken: aws.String(nextToken), - }, nil -} - func TestGetMetricDataExecutorTest(t *testing.T) { executor := &cloudWatchExecutor{} inputs := &cloudwatch.GetMetricDataInput{MetricDataQueries: []*cloudwatch.MetricDataQuery{}} - res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{counterForGetMetricDataWithContext: 1}, inputs) + mockMetricClient := &mocks.MetricsAPI{} + mockMetricClient.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return( + &cloudwatch.GetMetricDataOutput{ + MetricDataResults: []*cloudwatch.MetricDataResult{{Values: []*float64{aws.Float64(12.3), aws.Float64(23.5)}}}, + NextToken: aws.String("next"), + }, nil).Once() + mockMetricClient.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return( + &cloudwatch.GetMetricDataOutput{ + MetricDataResults: []*cloudwatch.MetricDataResult{{Values: []*float64{aws.Float64(100)}}}, + }, nil).Once() + res, err := executor.executeRequest(context.Background(), mockMetricClient, inputs) require.NoError(t, err) require.Len(t, res, 2) require.Len(t, res[0].MetricDataResults[0].Values, 2) diff --git a/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go b/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go index 656c775fb1a..8508f6e5697 100644 --- a/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go +++ b/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go @@ -10,18 +10,9 @@ import ( type FakeMetricsAPI struct { cloudwatchiface.CloudWatchAPI - cloudwatch.GetMetricDataOutput Metrics []*cloudwatch.Metric MetricsPerPage int - - CallsGetMetricDataWithContext []*cloudwatch.GetMetricDataInput -} - -func (c *FakeMetricsAPI) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { - c.CallsGetMetricDataWithContext = append(c.CallsGetMetricDataWithContext, input) - - return &c.GetMetricDataOutput, nil } func (c *FakeMetricsAPI) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error { @@ -58,12 +49,12 @@ func chunkSlice(slice []*cloudwatch.Metric, chunkSize int) [][]*cloudwatch.Metri return chunks } -type MetricsClient struct { +type MetricsAPI struct { cloudwatchiface.CloudWatchAPI mock.Mock } -func (m *MetricsClient) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { +func (m *MetricsAPI) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { args := m.Called(ctx, input, opts) return args.Get(0).(*cloudwatch.GetMetricDataOutput), args.Error(1) diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index 1e1b7dd0b37..c978120662f 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -32,28 +32,22 @@ func TestTimeSeriesQuery(t *testing.T) { t.Cleanup(func() { NewCWClient = origNewCWClient }) - var api mocks.FakeMetricsAPI + var api mocks.MetricsAPI NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { return &api } t.Run("Custom metrics", func(t *testing.T) { - api = mocks.FakeMetricsAPI{ - CloudWatchAPI: nil, - GetMetricDataOutput: cloudwatch.GetMetricDataOutput{ - NextToken: nil, - Messages: []*cloudwatch.MessageData{}, - MetricDataResults: []*cloudwatch.MetricDataResult{ - { - StatusCode: aws.String("Complete"), Id: aws.String("a"), Label: aws.String("NetworkOut"), Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{&now}, - }, - { - StatusCode: aws.String("Complete"), Id: aws.String("b"), Label: aws.String("NetworkIn"), Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{&now}, - }, + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{ + MetricDataResults: []*cloudwatch.MetricDataResult{ + { + StatusCode: aws.String("Complete"), Id: aws.String("a"), Label: aws.String("NetworkOut"), Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{&now}, }, - }, - } + { + StatusCode: aws.String("Complete"), Id: aws.String("b"), Label: aws.String("NetworkIn"), Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{&now}, + }}}, nil) im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { return DataSource{Settings: &models.CloudWatchSettings{}}, nil @@ -150,7 +144,7 @@ func Test_executeTimeSeriesQuery_getCWClient_is_called_once_per_region_and_GetMe NewCWClient = origNewCWClient }) - var mockMetricClient mocks.MetricsClient + var mockMetricClient mocks.MetricsAPI NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { return &mockMetricClient } @@ -164,7 +158,7 @@ func Test_executeTimeSeriesQuery_getCWClient_is_called_once_per_region_and_GetMe mockSessionCache.On("GetSession", mock.MatchedBy( func(config awsds.SessionConfig) bool { return config.Settings.Region == "us-east-1" })). // region from queries is asserted here Return(&session.Session{Config: &aws.Config{}}, nil).Once() - mockMetricClient = mocks.MetricsClient{} + mockMetricClient = mocks.MetricsAPI{} mockMetricClient.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) executor := newExecutor(im, newTestConfig(), mockSessionCache, featuremgmt.WithFeatures()) @@ -215,7 +209,7 @@ func Test_executeTimeSeriesQuery_getCWClient_is_called_once_per_region_and_GetMe sessionCache.On("GetSession", mock.MatchedBy( func(config awsds.SessionConfig) bool { return config.Settings.Region == "us-east-2" })). Return(&session.Session{Config: &aws.Config{}}, nil, nil).Once() - mockMetricClient = mocks.MetricsClient{} + mockMetricClient = mocks.MetricsAPI{} mockMetricClient.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) executor := newExecutor(im, newTestConfig(), sessionCache, featuremgmt.WithFeatures()) @@ -341,7 +335,7 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { NewCWClient = origNewCWClient }) - var api mocks.FakeMetricsAPI + var api mocks.MetricsAPI NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { return &api @@ -352,7 +346,8 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { }) t.Run("passes query label as GetMetricData label when dynamic labels feature toggle is enabled", func(t *testing.T) { - api = mocks.FakeMetricsAPI{} + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels)) query := newTestQuery(t, queryParameters{ Label: aws.String("${PROP('Period')} some words ${PROP('Dim.InstanceId')}"), @@ -373,11 +368,12 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { }) assert.NoError(t, err) - require.Len(t, api.CallsGetMetricDataWithContext, 1) - require.Len(t, api.CallsGetMetricDataWithContext[0].MetricDataQueries, 1) - require.NotNil(t, api.CallsGetMetricDataWithContext[0].MetricDataQueries[0].Label) - - assert.Equal(t, "${PROP('Period')} some words ${PROP('Dim.InstanceId')}", *api.CallsGetMetricDataWithContext[0].MetricDataQueries[0].Label) + require.Len(t, api.Calls, 1) + getMetricDataInput, ok := api.Calls[0].Arguments.Get(1).(*cloudwatch.GetMetricDataInput) + require.True(t, ok) + require.Len(t, getMetricDataInput.MetricDataQueries, 1) + require.NotNil(t, getMetricDataInput.MetricDataQueries[0].Label) + assert.Equal(t, "${PROP('Period')} some words ${PROP('Dim.InstanceId')}", *getMetricDataInput.MetricDataQueries[0].Label) }) testCases := map[string]struct { @@ -399,7 +395,8 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - api = mocks.FakeMetricsAPI{} + api = mocks.MetricsAPI{} + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil) executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, tc.feature) _, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{ @@ -417,10 +414,12 @@ func Test_QueryData_timeSeriesQuery_GetMetricDataWithContext(t *testing.T) { }) assert.NoError(t, err) - require.Len(t, api.CallsGetMetricDataWithContext, 1) - require.Len(t, api.CallsGetMetricDataWithContext[0].MetricDataQueries, 1) - - assert.Nil(t, api.CallsGetMetricDataWithContext[0].MetricDataQueries[0].Label) + assert.NoError(t, err) + require.Len(t, api.Calls, 1) + getMetricDataInput, ok := api.Calls[0].Arguments.Get(1).(*cloudwatch.GetMetricDataInput) + require.True(t, ok) + require.Len(t, getMetricDataInput.MetricDataQueries, 1) + require.Nil(t, getMetricDataInput.MetricDataQueries[0].Label) }) } } @@ -430,21 +429,20 @@ func Test_QueryData_response_data_frame_names(t *testing.T) { t.Cleanup(func() { NewCWClient = origNewCWClient }) - var api mocks.FakeMetricsAPI + var api mocks.MetricsAPI NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI { return &api } labelFromGetMetricData := "some label" - api = mocks.FakeMetricsAPI{ - GetMetricDataOutput: cloudwatch.GetMetricDataOutput{ + api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything). + Return(&cloudwatch.GetMetricDataOutput{ MetricDataResults: []*cloudwatch.MetricDataResult{ {StatusCode: aws.String("Complete"), Id: aws.String(queryId), Label: aws.String(labelFromGetMetricData), Values: []*float64{aws.Float64(1.0)}, Timestamps: []*time.Time{{}}}, - }, - }, - } + }}, nil) + im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { return DataSource{Settings: &models.CloudWatchSettings{}}, nil }) From 228ec4c0f35d97a82e9657728bbc444d7ad08308 Mon Sep 17 00:00:00 2001 From: Timur Olzhabayev Date: Tue, 8 Nov 2022 10:36:27 +0100 Subject: [PATCH 105/926] Chore: Switch Grafana to using faro libraries (#58186) --- package.json | 4 +- packages/grafana-runtime/package.json | 2 +- packages/grafana-runtime/src/utils/logging.ts | 10 +- .../EchoSrvTransport.ts | 6 +- .../GrafanaJavascriptAgentBackend.test.ts | 100 +++++++++-- .../GrafanaJavascriptAgentBackend.ts | 16 +- .../features/alerting/unified/Analytics.ts | 4 +- yarn.lock | 159 +++++++++--------- 8 files changed, 184 insertions(+), 117 deletions(-) diff --git a/package.json b/package.json index c258b484838..10cafee0435 100644 --- a/package.json +++ b/package.json @@ -246,12 +246,12 @@ "@daybrush/utils": "1.10.0", "@emotion/css": "11.10.5", "@emotion/react": "11.10.5", - "@grafana/agent-core": "0.4.0", - "@grafana/agent-web": "0.4.0", "@grafana/aws-sdk": "0.0.37", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", "@grafana/experimental": "1.0.1", + "@grafana/faro-core": "1.0.0-beta2", + "@grafana/faro-web-sdk": "1.0.0-beta2", "@grafana/google-sdk": "0.0.4", "@grafana/lezer-logql": "0.1.1", "@grafana/monaco-logql": "^0.0.6", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 02c7a0d9e33..37bae34afee 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -35,9 +35,9 @@ "typecheck": "tsc --emitDeclarationOnly false --noEmit" }, "dependencies": { - "@grafana/agent-web": "^0.4.0", "@grafana/data": "9.3.0-pre", "@grafana/e2e-selectors": "9.3.0-pre", + "@grafana/faro-web-sdk": "1.0.0-beta2", "@grafana/ui": "9.3.0-pre", "@sentry/browser": "6.19.7", "history": "4.10.1", diff --git a/packages/grafana-runtime/src/utils/logging.ts b/packages/grafana-runtime/src/utils/logging.ts index 6ad4670ef7b..b4369298ce7 100644 --- a/packages/grafana-runtime/src/utils/logging.ts +++ b/packages/grafana-runtime/src/utils/logging.ts @@ -1,6 +1,6 @@ import { captureMessage, captureException, Severity as LogLevel } from '@sentry/browser'; -import { agent, LogLevel as GrafanaLogLevel } from '@grafana/agent-web'; +import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk'; import { config } from '../config'; @@ -16,7 +16,7 @@ type Contexts = Record { - const originalModule = jest.requireActual('@grafana/agent-web'); +jest.mock('@grafana/faro-web-sdk', () => { + const originalModule = jest.requireActual('@grafana/faro-web-sdk'); return { __esModule: true, ...originalModule, - initializeAgent: jest.fn(), + initializeFaro: jest.fn(), }; }); @@ -52,8 +52,8 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { it('will set up FetchTransport if customEndpoint is provided', async () => { // arrange - const originalModule = jest.requireActual('@grafana/agent-web'); - jest.mocked(initializeAgent).mockImplementation(originalModule.initializeAgent); + const originalModule = jest.requireActual('@grafana/faro-web-sdk'); + jest.mocked(initializeFaro).mockImplementation(originalModule.initializeFaro); //act const backend = new GrafanaJavascriptAgentBackend(options); @@ -66,6 +66,19 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { it('will initialize GrafanaJavascriptAgent and set user', async () => { // arrange const mockedSetUser = jest.fn(); + const mockedInstrumentationsForConfig: Instrumentation[] = []; + const mockedInstrumentations = { + add: jest.fn(), + instrumentations: mockedInstrumentationsForConfig, + remove: jest.fn(), + }; + const mockedInternalLogger = { + prefix: 'Faro', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; const mockedAgent = () => { return { api: { @@ -75,24 +88,38 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { pushError: jest.fn(), pushMeasurement: jest.fn(), pushTraces: jest.fn(), + pushEvent: jest.fn(), initOTEL: jest.fn(), getOTEL: jest.fn(), getTraceContext: jest.fn(), + changeStacktraceParser: jest.fn(), + getStacktraceParser: jest.fn(), + isOTELInitialized: jest.fn(), + setSession: jest.fn(), + getSession: jest.fn(), + resetUser: jest.fn(), + resetSession: jest.fn(), }, config: { globalObjectKey: '', - instrumentations: [], preventGlobalExposure: false, transports: [], + instrumentations: mockedInstrumentationsForConfig, metas: [], parseStacktrace: jest.fn(), app: jest.fn(), paused: false, + dedupe: true, + isolate: false, + internalLoggerLevel: InternalLoggerLevel.ERROR, + unpatchedConsole: { ...console }, }, metas: { add: jest.fn(), remove: jest.fn(), value: {}, + addListener: jest.fn(), + removeListener: jest.fn(), }, transports: { add: jest.fn(), @@ -100,18 +127,27 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { transports: [], pause: jest.fn(), unpause: jest.fn(), + addBeforeSendHooks: jest.fn(), + addIgnoreErrorsPatterns: jest.fn(), + getBeforeSendHooks: jest.fn(), + isPaused: jest.fn(), + remove: jest.fn(), + removeBeforeSendHooks: jest.fn(), }, pause: jest.fn(), unpause: jest.fn(), + instrumentations: mockedInstrumentations, + internalLogger: mockedInternalLogger, + unpatchedConsole: { ...console }, }; }; - jest.mocked(initializeAgent).mockImplementation(mockedAgent); + jest.mocked(initializeFaro).mockImplementation(mockedAgent); //act new GrafanaJavascriptAgentBackend(options); //assert - expect(initializeAgent).toHaveBeenCalledTimes(1); + expect(initializeFaro).toHaveBeenCalledTimes(1); expect(mockedSetUser).toHaveBeenCalledTimes(1); expect(mockedSetUser).toHaveBeenCalledWith({ id: '504', @@ -124,6 +160,19 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { it('will forward events to transports', async () => { //arrange const mockedSetUser = jest.fn(); + const mockedInstrumentationsForConfig: Instrumentation[] = []; + const mockedInstrumentations = { + add: jest.fn(), + instrumentations: mockedInstrumentationsForConfig, + remove: jest.fn(), + }; + const mockedInternalLogger = { + prefix: 'Faro', + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; const mockedAgent = () => { return { api: { @@ -133,24 +182,38 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { pushError: jest.fn(), pushMeasurement: jest.fn(), pushTraces: jest.fn(), + pushEvent: jest.fn(), initOTEL: jest.fn(), getOTEL: jest.fn(), getTraceContext: jest.fn(), + changeStacktraceParser: jest.fn(), + getStacktraceParser: jest.fn(), + isOTELInitialized: jest.fn(), + setSession: jest.fn(), + getSession: jest.fn(), + resetUser: jest.fn(), + resetSession: jest.fn(), }, config: { globalObjectKey: '', - instrumentations: [], preventGlobalExposure: false, transports: [], + instrumentations: mockedInstrumentationsForConfig, metas: [], parseStacktrace: jest.fn(), app: jest.fn(), paused: false, + dedupe: true, + isolate: false, + internalLoggerLevel: InternalLoggerLevel.ERROR, + unpatchedConsole: { ...console }, }, metas: { add: jest.fn(), remove: jest.fn(), value: {}, + addListener: jest.fn(), + removeListener: jest.fn(), }, transports: { add: jest.fn(), @@ -158,13 +221,22 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { transports: [], pause: jest.fn(), unpause: jest.fn(), + addBeforeSendHooks: jest.fn(), + addIgnoreErrorsPatterns: jest.fn(), + getBeforeSendHooks: jest.fn(), + isPaused: jest.fn(), + remove: jest.fn(), + removeBeforeSendHooks: jest.fn(), }, pause: jest.fn(), unpause: jest.fn(), + instrumentations: mockedInstrumentations, + internalLogger: mockedInternalLogger, + unpatchedConsole: { ...console }, }; }; - jest.mocked(initializeAgent).mockImplementation(mockedAgent); + jest.mocked(initializeFaro).mockImplementation(mockedAgent); const backend = new GrafanaJavascriptAgentBackend({ ...options, preventGlobalExposure: true, @@ -195,8 +267,8 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { // // use actual GrafanaJavascriptAgent & mock window.fetch // // arrange - // const originalModule = jest.requireActual('@grafana/agent-web'); - // jest.mocked(initializeAgent).mockImplementation(originalModule.initializeAgent); + // const originalModule = jest.requireActual('@grafana/faro-web-sdk'); + // jest.mocked(initializeFaro).mockImplementation(originalModule.initializeFaro); // const fetchSpy = (window.fetch = jest.fn()); // fetchSpy.mockResolvedValue({ status: 200 } as Response); // const echo = new Echo({ debug: true }); diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts index 616dcf5d2e9..ddb78077f4e 100644 --- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts +++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.ts @@ -1,14 +1,14 @@ -import { BaseTransport } from '@grafana/agent-core'; +import { BuildInfo } from '@grafana/data'; +import { BaseTransport } from '@grafana/faro-core'; import { - initializeAgent, + initializeFaro, defaultMetas, BrowserConfig, ErrorsInstrumentation, ConsoleInstrumentation, WebVitalsInstrumentation, FetchTransport, -} from '@grafana/agent-web'; -import { BuildInfo } from '@grafana/data'; +} from '@grafana/faro-web-sdk'; import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime'; import { EchoSrvTransport } from './EchoSrvTransport'; @@ -27,7 +27,7 @@ export class GrafanaJavascriptAgentBackend implements EchoBackend { supportedEvents = [EchoEventType.GrafanaJavascriptAgent]; - private agentInstance; + private faroInstance; transports: BaseTransport[]; constructor(public options: GrafanaJavascriptAgentBackendOptions) { @@ -51,7 +51,7 @@ export class GrafanaJavascriptAgentBackend // initialize GrafanaJavascriptAgent so it can set up its hooks and start collecting errors const grafanaJavaScriptAgentOptions: BrowserConfig = { - globalObjectKey: options.globalObjectKey || 'grafanaAgent', + globalObjectKey: options.globalObjectKey || 'faro', preventGlobalExposure: options.preventGlobalExposure || false, app: { version: options.buildInfo.version, @@ -74,10 +74,10 @@ export class GrafanaJavascriptAgentBackend }, ], }; - this.agentInstance = initializeAgent(grafanaJavaScriptAgentOptions); + this.faroInstance = initializeFaro(grafanaJavaScriptAgentOptions); if (options.user) { - this.agentInstance.api.setUser({ + this.faroInstance.api.setUser({ id: options.user.id, attributes: { orgId: String(options.user.orgId) || '', diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index dee5f9b0dd9..62eb16a5628 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -1,4 +1,4 @@ -import { agent, LogLevel as GrafanaLogLevel } from '@grafana/agent-web'; +import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk'; import { config } from '@grafana/runtime/src'; export const LogMessages = { @@ -15,7 +15,7 @@ export const LogMessages = { // logInfo from '@grafana/runtime' should be used, but it doesn't handle Grafana JS Agent and Sentry correctly export function logInfo(message: string, context: Record = {}) { if (config.grafanaJavascriptAgent.enabled) { - agent.api.pushLog([message], { + faro.api.pushLog([message], { level: GrafanaLogLevel.INFO, context: { ...context, module: 'Alerting' }, }); diff --git a/yarn.lock b/yarn.lock index b4e12cf68a2..faa565e44dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4291,29 +4291,6 @@ __metadata: languageName: unknown linkType: soft -"@grafana/agent-core@npm:0.4.0, @grafana/agent-core@npm:^0.4.0": - version: 0.4.0 - resolution: "@grafana/agent-core@npm:0.4.0" - dependencies: - "@opentelemetry/api": ^1.1.0 - "@opentelemetry/api-metrics": ^0.29.1 - "@opentelemetry/otlp-transformer": ^0.29.1 - uuid: ^8.3.2 - checksum: a71669dd0ec00f8e97dcf9e525b77221b64989918ab6a86c606caa87297a559c8da1f886cfa4c7ac7ade483dfaec24cf9733c93962f271c79540828ba06271a3 - languageName: node - linkType: hard - -"@grafana/agent-web@npm:0.4.0, @grafana/agent-web@npm:^0.4.0": - version: 0.4.0 - resolution: "@grafana/agent-web@npm:0.4.0" - dependencies: - "@grafana/agent-core": ^0.4.0 - ua-parser-js: ^1.0.2 - web-vitals: ^2.1.4 - checksum: 149b23dd387a70dfbc0f555c25712295e0eadcafcd13eb97d88e584b36388ddc819f2c59435101f7747151576cc3f616739efc8ad77eb77b84bd0c02204f9113 - languageName: node - linkType: hard - "@grafana/aws-sdk@npm:0.0.37": version: 0.0.37 resolution: "@grafana/aws-sdk@npm:0.0.37" @@ -4482,6 +4459,29 @@ __metadata: languageName: node linkType: hard +"@grafana/faro-core@npm:1.0.0-beta2, @grafana/faro-core@npm:^1.0.0-beta2": + version: 1.0.0-beta2 + resolution: "@grafana/faro-core@npm:1.0.0-beta2" + dependencies: + "@opentelemetry/api": ^1.1.0 + "@opentelemetry/api-metrics": ^0.33.0 + "@opentelemetry/otlp-transformer": ^0.33.0 + fast-deep-equal: ^3.1.3 + checksum: 0c807f5212e502313b149b087e3f2c0cebf59bfe95bcd558f6fd421727a6f7eeb735abb12661f9b76e14bbcebca4773763332a1fb992461e4a673f1326a9acce + languageName: node + linkType: hard + +"@grafana/faro-web-sdk@npm:1.0.0-beta2": + version: 1.0.0-beta2 + resolution: "@grafana/faro-web-sdk@npm:1.0.0-beta2" + dependencies: + "@grafana/faro-core": ^1.0.0-beta2 + ua-parser-js: ^1.0.32 + web-vitals: ^3.0.4 + checksum: 7919c4856653880c71d384b14e8e8a7fa2edc17cf57bdc55dc6afd8a8c94b6787a0b9186540b6fb1c4f6d720de7ec8b3ddd00517a2e8117ede94de5b3f8c5559 + languageName: node + linkType: hard + "@grafana/google-sdk@npm:0.0.4": version: 0.0.4 resolution: "@grafana/google-sdk@npm:0.0.4" @@ -4511,9 +4511,9 @@ __metadata: version: 0.0.0-use.local resolution: "@grafana/runtime@workspace:packages/grafana-runtime" dependencies: - "@grafana/agent-web": ^0.4.0 "@grafana/data": 9.3.0-pre "@grafana/e2e-selectors": 9.3.0-pre + "@grafana/faro-web-sdk": 1.0.0-beta2 "@grafana/tsconfig": ^1.2.0-rc1 "@grafana/ui": 9.3.0-pre "@rollup/plugin-commonjs": 23.0.2 @@ -7151,12 +7151,12 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api-metrics@npm:0.29.2, @opentelemetry/api-metrics@npm:^0.29.1": - version: 0.29.2 - resolution: "@opentelemetry/api-metrics@npm:0.29.2" +"@opentelemetry/api-metrics@npm:0.33.0, @opentelemetry/api-metrics@npm:^0.33.0": + version: 0.33.0 + resolution: "@opentelemetry/api-metrics@npm:0.33.0" dependencies: "@opentelemetry/api": ^1.0.0 - checksum: 6197a1f05c8bfc72db7052b65d0612155f675282d796f4566fc1f99228f6c0b21df52bf9d865456992298d1a1720ea58dd79ec4b27b85563ec13f820dcaf2d3a + checksum: 8c4fc342e96bc3bea8d5f152faab7dec479c75b9a9b0796a4a8f17525734dcabab2366c0ea0f3d1f3e575d5e26315086b0ddf6ceda5625f5bd6322f425a2c074 languageName: node linkType: hard @@ -7186,14 +7186,14 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:1.3.1": - version: 1.3.1 - resolution: "@opentelemetry/core@npm:1.3.1" +"@opentelemetry/core@npm:1.7.0": + version: 1.7.0 + resolution: "@opentelemetry/core@npm:1.7.0" dependencies: - "@opentelemetry/semantic-conventions": 1.3.1 + "@opentelemetry/semantic-conventions": 1.7.0 peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.2.0" - checksum: a59d0e8b7af2054d4b741a076abb992fb9bb241b1e7e2563a7d03b8a810155eb5c5c4eab28ebae39ce67d9ae66ae2a8d5d038de3b7ddc6301ac636840ceb876c + "@opentelemetry/api": ">=1.0.0 <1.3.0" + checksum: 94fcae57c3c2c3a1cff6311246f32a228b216533449bfcec2f8eb03ea023f0ace4e0929c8cf5145772c6f25263d5f2d5d3485a39ab0ced4e11f5a0fed7497e9c languageName: node linkType: hard @@ -7212,18 +7212,18 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/otlp-transformer@npm:^0.29.1": - version: 0.29.2 - resolution: "@opentelemetry/otlp-transformer@npm:0.29.2" +"@opentelemetry/otlp-transformer@npm:^0.33.0": + version: 0.33.0 + resolution: "@opentelemetry/otlp-transformer@npm:0.33.0" dependencies: - "@opentelemetry/api-metrics": 0.29.2 - "@opentelemetry/core": 1.3.1 - "@opentelemetry/resources": 1.3.1 - "@opentelemetry/sdk-metrics-base": 0.29.2 - "@opentelemetry/sdk-trace-base": 1.3.1 + "@opentelemetry/api-metrics": 0.33.0 + "@opentelemetry/core": 1.7.0 + "@opentelemetry/resources": 1.7.0 + "@opentelemetry/sdk-metrics": 0.33.0 + "@opentelemetry/sdk-trace-base": 1.7.0 peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.2.0" - checksum: e86dc023c96fcf2faa72d47300bb43d2820a5f3f75446d17b109c4a16accec2257642dbe0856ec65ef4c6a9ba9ac551ace5a429bd7222da64563acd7002f8698 + "@opentelemetry/api": ">=1.0.0 <1.3.0" + checksum: f2a68957588a5bf7de3974dd210440dc64f29dd93c3f6f79c2e0cf69d3e487432cfec6d2c14752d7ba007f93140a23f8ea39135451e7bf455789b4a2d8ad84d1 languageName: node linkType: hard @@ -7239,15 +7239,15 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/resources@npm:1.3.1": - version: 1.3.1 - resolution: "@opentelemetry/resources@npm:1.3.1" +"@opentelemetry/resources@npm:1.7.0": + version: 1.7.0 + resolution: "@opentelemetry/resources@npm:1.7.0" dependencies: - "@opentelemetry/core": 1.3.1 - "@opentelemetry/semantic-conventions": 1.3.1 + "@opentelemetry/core": 1.7.0 + "@opentelemetry/semantic-conventions": 1.7.0 peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.2.0" - checksum: 2aeb76e23364f2ede34d27c912d3489e219dbdb430c7ae33e30c38d4451f05fc58c0ff05332a8eac428d889f456692450417b8477585089cd047165fb25681ea + "@opentelemetry/api": ">=1.0.0 <1.3.0" + checksum: 9d669e4170120ef240757f9d82b5ef411335606114d19bd9f7a534a8328638871de0a06487f5da2dc0eb2ea540bb3ccbeea2f41c75a27de4e13e270452dd38eb languageName: node linkType: hard @@ -7265,17 +7265,17 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/sdk-metrics-base@npm:0.29.2": - version: 0.29.2 - resolution: "@opentelemetry/sdk-metrics-base@npm:0.29.2" +"@opentelemetry/sdk-metrics@npm:0.33.0": + version: 0.33.0 + resolution: "@opentelemetry/sdk-metrics@npm:0.33.0" dependencies: - "@opentelemetry/api-metrics": 0.29.2 - "@opentelemetry/core": 1.3.1 - "@opentelemetry/resources": 1.3.1 + "@opentelemetry/api-metrics": 0.33.0 + "@opentelemetry/core": 1.7.0 + "@opentelemetry/resources": 1.7.0 lodash.merge: 4.6.2 peerDependencies: "@opentelemetry/api": ^1.0.0 - checksum: 3518b881991ce13bc1a93346889bb4e5e9581528b511151981049b60bf78acf85f44701597e303f23629f2282ba36ac26d55c39740ef288e227d8253658933aa + checksum: 2c99c7ece4e545a3da0280e8dc9699458fdbb5c2bf9bcf09a9524612a844b28516d864ab1e0b5bedd12f9cccfc5d4f01663686538e8144b4d0185d2f8a581f0e languageName: node linkType: hard @@ -7293,16 +7293,16 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/sdk-trace-base@npm:1.3.1": - version: 1.3.1 - resolution: "@opentelemetry/sdk-trace-base@npm:1.3.1" +"@opentelemetry/sdk-trace-base@npm:1.7.0": + version: 1.7.0 + resolution: "@opentelemetry/sdk-trace-base@npm:1.7.0" dependencies: - "@opentelemetry/core": 1.3.1 - "@opentelemetry/resources": 1.3.1 - "@opentelemetry/semantic-conventions": 1.3.1 + "@opentelemetry/core": 1.7.0 + "@opentelemetry/resources": 1.7.0 + "@opentelemetry/semantic-conventions": 1.7.0 peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.2.0" - checksum: 9f3074f226854ff285e15d1f636f6c912b9760306c3617fd731dc2c9cb4816d59b7b74185ce1d3be9131d4852a881599cbba2d721cc02871bdf2827a028f7a62 + "@opentelemetry/api": ">=1.0.0 <1.3.0" + checksum: f6ebfe1614d481ab11f4ebca4ae45ae92790e9b27a6b30cdeedf968918ac85e6d5cd695dd26ba3d0db0e665830a9c73ed25b4bd286c019c4adf084a05776bb9c languageName: node linkType: hard @@ -7313,13 +7313,6 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:1.3.1": - version: 1.3.1 - resolution: "@opentelemetry/semantic-conventions@npm:1.3.1" - checksum: 83fa3b8a8198f6a5265a4191a89cfcce042240b92e034cf29631b0fe749993f4f74f4d21d89b10f6b05984246424adf8d037f8be507fcf0adf60933ab7143f07 - languageName: node - linkType: hard - "@opentelemetry/semantic-conventions@npm:1.7.0": version: 1.7.0 resolution: "@opentelemetry/semantic-conventions@npm:1.7.0" @@ -21519,14 +21512,14 @@ __metadata: "@emotion/css": 11.10.5 "@emotion/eslint-plugin": 11.10.0 "@emotion/react": 11.10.5 - "@grafana/agent-core": 0.4.0 - "@grafana/agent-web": 0.4.0 "@grafana/aws-sdk": 0.0.37 "@grafana/data": "workspace:*" "@grafana/e2e": "workspace:*" "@grafana/e2e-selectors": "workspace:*" "@grafana/eslint-config": 5.0.0 "@grafana/experimental": 1.0.1 + "@grafana/faro-core": 1.0.0-beta2 + "@grafana/faro-web-sdk": 1.0.0-beta2 "@grafana/google-sdk": 0.0.4 "@grafana/lezer-logql": 0.1.1 "@grafana/monaco-logql": ^0.0.6 @@ -37399,10 +37392,10 @@ __metadata: languageName: node linkType: hard -"ua-parser-js@npm:^1.0.2": - version: 1.0.2 - resolution: "ua-parser-js@npm:1.0.2" - checksum: ff7f6d79a9c1a38aa85a0e751040fc7e17a0b621bda876838d14ebe55aca4e50e68da0350f181e58801c2d8a35e7db4e12473776e558910c4b7cabcec96aa3bf +"ua-parser-js@npm:^1.0.32": + version: 1.0.32 + resolution: "ua-parser-js@npm:1.0.32" + checksum: 79a80efd9c21511fdafc042ab748e0e93c8cdb0e8925bf6d48ad7dbb08e808c60fcecd49e679670def44ef428c005aa1810810f6773e7d8135a7817338080813 languageName: node linkType: hard @@ -38419,10 +38412,10 @@ __metadata: languageName: node linkType: hard -"web-vitals@npm:^2.1.4": - version: 2.1.4 - resolution: "web-vitals@npm:2.1.4" - checksum: 03d3f47dbf55c3dce07beb0ff5de8ddd52e2d0a53a8df5c84e7a16dda93543341d67231fa79b1d9772b091419af4ec0fd395b8bcf451a0e26846e3f76b3d0efc +"web-vitals@npm:^3.0.4": + version: 3.0.4 + resolution: "web-vitals@npm:3.0.4" + checksum: b618a8e049e0c64948eea09c372db490802bcc8bcb30230a2bc69d9e243b1a2fa54d0f0ae19d5e63f19381df86db2a9260ca15ef620e10024888eab487a77d56 languageName: node linkType: hard From 96cdf77995c6fcd47256e19dccece145ebad59aa Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue, 8 Nov 2022 11:52:07 +0200 Subject: [PATCH 106/926] Revert "Chore: Refactor quota service (#57586)" (#58394) This reverts commit 326ea86a579ed927b1999bba5f2c0a35e26506d9. --- pkg/api/admin_users.go | 2 +- pkg/api/api.go | 22 +- pkg/api/common_test.go | 18 +- pkg/api/dashboard.go | 2 +- pkg/api/dashboard_test.go | 26 +- pkg/api/folder_test.go | 2 +- pkg/api/metrics_test.go | 8 +- pkg/api/org_test.go | 15 +- pkg/api/org_users_test.go | 60 +-- pkg/api/plugin_dashboards_test.go | 2 +- pkg/api/pluginproxy/ds_proxy_test.go | 118 ++--- pkg/api/plugins_test.go | 2 +- pkg/api/quota.go | 88 ++-- pkg/api/quota_test.go | 31 +- pkg/api/user_test.go | 4 +- pkg/cmd/grafana-cli/runner/wire.go | 2 +- pkg/middleware/quota.go | 6 +- pkg/middleware/quota_test.go | 71 ++- pkg/models/quotas.go | 91 ++++ pkg/models/user_token.go | 4 + pkg/server/wire.go | 2 +- .../resourcepermissions/service_test.go | 4 +- .../annotationsimpl/xorm_store_test.go | 9 +- pkg/services/apikey/apikeyimpl/apikey.go | 54 +- pkg/services/apikey/apikeyimpl/sqlx_store.go | 33 -- pkg/services/apikey/apikeyimpl/store.go | 3 - pkg/services/apikey/apikeyimpl/xorm_store.go | 46 -- pkg/services/apikey/model.go | 6 - pkg/services/auth/auth_token.go | 51 +- pkg/services/auth/auth_token_test.go | 13 +- pkg/services/auth/model.go | 6 - pkg/services/dashboardimport/api/api.go | 11 +- pkg/services/dashboardimport/api/api_test.go | 5 +- pkg/services/dashboards/dashboard.go | 2 - pkg/services/dashboards/database/acl_test.go | 6 +- pkg/services/dashboards/database/database.go | 86 +--- .../database/database_folder_test.go | 26 +- .../database/database_provisioning_test.go | 5 +- .../dashboards/database/database_test.go | 20 +- pkg/services/dashboards/models.go | 6 - .../dashboard_service_integration_test.go | 75 ++- pkg/services/dashboards/store_mock.go | 5 - pkg/services/datasources/models.go | 6 - .../datasources/service/datasource.go | 43 +- .../datasources/service/datasource_test.go | 45 +- pkg/services/datasources/service/store.go | 48 -- .../folder/folderimpl/sqlstore_test.go | 4 +- .../guardian/accesscontrol_guardian_test.go | 8 +- .../libraryelements/libraryelements_test.go | 15 +- .../librarypanels/librarypanels_test.go | 15 +- .../login/loginservice/loginservice.go | 18 +- .../login/loginservice/loginservice_test.go | 8 +- pkg/services/ngalert/api/api.go | 25 - pkg/services/ngalert/api/api_ruler.go | 2 +- pkg/services/ngalert/api/persist.go | 2 - pkg/services/ngalert/models/alert_rule.go | 6 - pkg/services/ngalert/ngalert.go | 42 -- pkg/services/ngalert/provisioning/persist.go | 2 +- .../provisioning/quota_checker_mock.go | 29 +- pkg/services/ngalert/store/alert_rule.go | 24 - pkg/services/ngalert/tests/fakes/rules.go | 4 - pkg/services/ngalert/tests/util.go | 7 +- pkg/services/org/model.go | 6 - pkg/services/org/orgimpl/org.go | 51 +- pkg/services/org/orgimpl/org_test.go | 5 - pkg/services/org/orgimpl/store.go | 70 --- .../publicdashboards/api/query_test.go | 4 +- .../database/database_test.go | 48 +- .../publicdashboards/service/query_test.go | 13 +- .../publicdashboards/service/service_test.go | 30 +- pkg/services/query/query_test.go | 5 +- pkg/services/quota/context.go | 42 -- pkg/services/quota/model.go | 210 +------- pkg/services/quota/quota.go | 23 +- pkg/services/quota/quotaimpl/quota.go | 452 ++++++----------- pkg/services/quota/quotaimpl/quota_test.go | 469 +----------------- pkg/services/quota/quotaimpl/store.go | 115 +---- pkg/services/quota/quotaimpl/store_test.go | 4 +- pkg/services/quota/quotatest/fake.go | 38 +- .../kvstore/migrations/datasource_mig_test.go | 6 +- pkg/services/serviceaccounts/api/api_test.go | 26 +- .../serviceaccounts/api/token_test.go | 9 +- .../serviceaccounts/database/database_test.go | 8 +- pkg/services/serviceaccounts/tests/common.go | 7 +- pkg/services/sqlstore/mockstore/mockstore.go | 28 ++ pkg/services/sqlstore/quota.go | 315 ++++++++++++ pkg/services/sqlstore/quota_test.go | 301 +++++++++++ pkg/services/sqlstore/store.go | 7 + pkg/services/store/service.go | 68 +-- pkg/services/store/service_test.go | 4 +- pkg/services/user/model.go | 5 - pkg/services/user/userimpl/store.go | 19 - pkg/services/user/userimpl/user.go | 50 +- pkg/services/user/userimpl/user_test.go | 4 - pkg/setting/setting.go | 10 +- pkg/setting/setting_quota.go | 48 +- .../api/alerting/api_alertmanager_test.go | 29 +- pkg/tests/api/alerting/testing.go | 55 -- pkg/tsdb/legacydata/service/service_test.go | 9 +- 99 files changed, 1398 insertions(+), 2596 deletions(-) create mode 100644 pkg/models/quotas.go delete mode 100644 pkg/services/quota/context.go create mode 100644 pkg/services/sqlstore/quota.go create mode 100644 pkg/services/sqlstore/quota_test.go diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index e164cc49d63..7daf81be95c 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -245,7 +245,7 @@ func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { return nil }) g.Go(func() error { - if err := hs.QuotaService.DeleteQuotaForUser(ctx, cmd.UserID); err != nil { + if err := hs.QuotaService.DeleteByUser(ctx, cmd.UserID); err != nil { return err } return nil diff --git a/pkg/api/api.go b/pkg/api/api.go index 0a76dfab147..5692a904322 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -36,16 +36,12 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "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/correlations" "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/org" publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -73,8 +69,8 @@ func (hs *HTTPServer) registerRoutes() { // not logged in views r.Get("/logout", hs.Logout) - r.Post("/login", quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPost)) - r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin) + r.Post("/login", quota("session"), routing.Wrap(hs.LoginPost)) + r.Get("/login/:name", quota("session"), hs.OAuthLogin) r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) @@ -177,7 +173,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/verify", hs.Index) r.Get("/signup", hs.Index) r.Get("/api/user/signup/options", routing.Wrap(GetSignUpOptions)) - r.Post("/api/user/signup", quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.SignUp)) + r.Post("/api/user/signup", quota("user"), routing.Wrap(hs.SignUp)) r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2)) // invited @@ -196,7 +192,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index) // api renew session based on cookie - r.Get("/api/login/ping", quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginAPIPing)) + r.Get("/api/login/ping", quota("session"), routing.Wrap(hs.LoginAPIPing)) // expose plugin file system assets r.Get("/public/plugins/:pluginId/*", hs.getPluginAssets) @@ -302,13 +298,13 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Put("/address", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrgAddress)) orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsersWithPaging)) - orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.AddOrgUserToCurrentOrg)) + orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota("user"), routing.Wrap(hs.AddOrgUserToCurrentOrg)) orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUserForCurrentOrg)) orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUserForCurrentOrg)) // invites orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.GetPendingOrgInvites)) - orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota(user.QuotaTargetSrv), quota(user.QuotaTargetSrv), routing.Wrap(hs.AddOrgInvite)) + orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota("user"), routing.Wrap(hs.AddOrgInvite)) orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.RevokeInvite)) // prefs @@ -335,7 +331,7 @@ func (hs *HTTPServer) registerRoutes() { }) // create new org - apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota(org.QuotaTargetSrv), routing.Wrap(hs.CreateOrg)) + apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota("org"), routing.Wrap(hs.CreateOrg)) // search all orgs apiRoute.Get("/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.SearchOrgs)) @@ -362,7 +358,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { apikeyIDScope := ac.Scope("apikeys", "id", ac.Parameter(":id")) keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead)), routing.Wrap(hs.GetAPIKeys)) - keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota(string(apikey.QuotaTargetSrv)), routing.Wrap(hs.AddAPIKey)) + keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey)) keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey)) }) @@ -377,7 +373,7 @@ func (hs *HTTPServer) registerRoutes() { uidScope := datasources.ScopeProvider.GetResourceScopeUID(ac.Parameter(":uid")) nameScope := datasources.ScopeProvider.GetResourceScopeName(ac.Parameter(":name")) datasourceRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSources)) - datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota(string(datasources.QuotaTargetSrv)), routing.Wrap(hs.AddDataSource)) + datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota("data_source"), routing.Wrap(hs.AddDataSource)) datasourceRoute.Put("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, idScope)), routing.Wrap(hs.UpdateDataSourceByID)) datasourceRoute.Put("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(hs.UpdateDataSourceByUID)) datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, idScope)), routing.Wrap(hs.DeleteDataSourceById)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 0ecb6a38188..8ca9c240b8e 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -45,6 +45,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/preference/preftest" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/search" @@ -248,13 +249,15 @@ func (s *fakeRenderService) Init() error { } func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []accesscontrol.Permission) (*scenarioContext, *HTTPServer) { - store := sqlstore.InitTestDB(t) + cfg.Quota.Enabled = false + + store := db.InitTestDB(t) hs := &HTTPServer{ Cfg: cfg, Live: newTestLive(t, store), License: &licensing.OSSLicensingService{}, Features: featuremgmt.WithFeatures(), - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{Cfg: cfg}, RouteRegister: routing.NewRouteRegister(), AccessControl: accesscontrolmock.New().WithPermissions(permissions), searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()), @@ -373,9 +376,7 @@ func setupHTTPServerWithCfgDb( routeRegister := routing.NewRouteRegister() teamService := teamimpl.ProvideService(db, cfg) cfg.IsFeatureToggleEnabled = features.IsEnabled - quotaService := quotatest.New(false, nil) - dashboardsStore, err := dashboardsstore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, cfg), quotaService) - require.NoError(t, err) + dashboardsStore := dashboardsstore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, cfg)) var acmock *accesscontrolmock.Mock var ac accesscontrol.AccessControl @@ -401,8 +402,7 @@ func setupHTTPServerWithCfgDb( acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), featuremgmt.WithFeatures()) require.NoError(t, err) ac = acimpl.ProvideAccessControl(cfg) - userSvc, err = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService(), quotatest.New(false, nil)) - require.NoError(t, err) + userSvc = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService()) } teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, license, acService, teamService, userSvc) require.NoError(t, err) @@ -412,7 +412,7 @@ func setupHTTPServerWithCfgDb( Cfg: cfg, Features: features, Live: newTestLive(t, db), - QuotaService: quotaService, + QuotaService: "aimpl.Service{Cfg: cfg}, RouteRegister: routeRegister, SQLStore: store, License: &licensing.OSSLicensingService{}, @@ -497,7 +497,7 @@ func SetupAPITestServer(t *testing.T, opts ...APITestServerOption) *webtest.Serv RouteRegister: routing.NewRouteRegister(), License: &licensing.OSSLicensingService{}, Features: featuremgmt.WithFeatures(), - QuotaService: quotatest.New(false, nil), + QuotaService: quotatest.NewQuotaServiceFake(), searchUsersService: &searchusers.OSSService{}, } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index f8d47c8866e..2f3f335d389 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -407,7 +407,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa dash := cmd.GetDashboardModel() newDashboard := dash.Id == 0 if newDashboard { - limitReached, err := hs.QuotaService.QuotaReached(c, dashboards.QuotaTargetSrv) + limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") if err != nil { return response.Error(500, "failed to get quota", err) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 6c506ee49a1..b4fc7c52e3c 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -39,7 +39,7 @@ import ( pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/preference/preftest" "github.com/grafana/grafana/pkg/services/provisioning" - "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -150,7 +150,6 @@ func TestDashboardAPIEndpoint(t *testing.T) { DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, Coremodels: registry.NewBase(nil), - QuotaService: quotatest.New(false, nil), } setUp := func() { @@ -991,12 +990,9 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr provisioningService = provisioning.NewProvisioningServiceMock(context.Background()) } - var err error if dashboardStore == nil { sql := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err = database.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) - require.NoError(t, err) + dashboardStore = database.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) } libraryPanelsService := mockLibraryPanelService{} @@ -1035,7 +1031,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr require.Equal(sc.t, 200, sc.resp.Code) dash := dtos.DashboardFullWithMeta{} - err = json.NewDecoder(sc.resp.Body).Decode(&dash) + err := json.NewDecoder(sc.resp.Body).Decode(&dash) require.NoError(sc.t, err) return dash @@ -1081,10 +1077,12 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { cfg := setting.NewCfg() hs := HTTPServer{ - Cfg: cfg, - ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), - Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: quotatest.New(false, nil), + Cfg: cfg, + ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), + Live: newTestLive(t, db.InitTestDB(t)), + QuotaService: "aimpl.Service{ + Cfg: cfg, + }, pluginStore: &plugins.FakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, @@ -1118,7 +1116,7 @@ func postValidateScenario(t *testing.T, desc string, url string, routePattern st Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{Cfg: cfg}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, @@ -1154,7 +1152,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{Cfg: cfg}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, @@ -1192,7 +1190,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{Cfg: cfg}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, DashboardService: mock, diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 1d759717a91..bc7ace258a9 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -146,7 +146,7 @@ func TestHTTPServer_FolderMetadata(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.folderService = folderService hs.AccessControl = acmock.New() - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) t.Run("Should attach access control metadata to multiple folders", func(t *testing.T) { diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index 3992a5cac1d..8f7961a9daf 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -94,12 +94,12 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) { serverFeatureEnabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagDatasourceQueryMultiStatus, true) - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) serverFeatureDisabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagDatasourceQueryMultiStatus, false) - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) t.Run("Status code is 400 when data source response has an error and feature toggle is disabled", func(t *testing.T) { @@ -142,7 +142,7 @@ func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) { ) httpServer := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) t.Run("Status code is 500 and a secrets plugin error is returned if there is a problem getting secrets from the remote plugin", func(t *testing.T) { @@ -294,7 +294,7 @@ func TestDataSourceQueryError(t *testing.T) { pluginClient.ProvideService(r, &config.Cfg{}), &fakeOAuthTokenService{}, ) - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) req := srv.NewPostRequest("/api/ds/query", strings.NewReader(tc.request)) webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleViewer}) diff --git a/pkg/api/org_test.go b/pkg/api/org_test.go index 49ed8000aae..e97d330e312 100644 --- a/pkg/api/org_test.go +++ b/pkg/api/org_test.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" @@ -105,8 +104,7 @@ func TestAPIEndpoint_PutCurrentOrg_LegacyAccessControl(t *testing.T) { }) setInitCtxSignedInOrgAdmin(sc.initCtx) - sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) - require.NoError(t, err) + sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) t.Run("Admin can update current org", func(t *testing.T) { response := callAPI(sc.server, http.MethodPut, putCurrentOrgURL, input, t) assert.Equal(t, http.StatusOK, response.Code) @@ -120,8 +118,7 @@ func TestAPIEndpoint_PutCurrentOrg_AccessControl(t *testing.T) { _, err := sc.db.CreateOrgWithMember("TestOrg", sc.initCtx.UserID) require.NoError(t, err) - sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) - require.NoError(t, err) + sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) input := strings.NewReader(testUpdateOrgNameForm) t.Run("AccessControl allows updating current org with correct permissions", func(t *testing.T) { @@ -439,9 +436,7 @@ func TestAPIEndpoint_PutOrg_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) - var err error - sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) - require.NoError(t, err) + sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) // Create two orgs, to update another one than the logged in one setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) @@ -461,9 +456,7 @@ func TestAPIEndpoint_PutOrg_LegacyAccessControl(t *testing.T) { func TestAPIEndpoint_PutOrg_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) - var err error - sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) - require.NoError(t, err) + sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) // Create two orgs, to update another one than the logged in one setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 3dbe71300ae..71a6b00db7d 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -22,7 +22,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/org/orgtest" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -390,13 +389,11 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl - var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) - require.NoError(t, err) - hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotatest.New(false, nil)) - require.NoError(t, err) + hs.userService = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), + ) + hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) @@ -406,7 +403,7 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { require.Equal(t, tc.expectedCode, response.Code) var userList []*models.OrgUserDTO - err = json.NewDecoder(response.Body).Decode(&userList) + err := json.NewDecoder(response.Body).Decode(&userList) require.NoError(t, err) if tc.expectedMetadata != nil { @@ -496,14 +493,11 @@ func TestGetOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl - var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - quotaService := quotatest.New(false, nil) - hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) - require.NoError(t, err) - hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) - require.NoError(t, err) + hs.userService = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), + ) + hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) }) setInitCtxSignedInUser(sc.initCtx, tc.user) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -604,11 +598,10 @@ func TestPostOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl - var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) - require.NoError(t, err) + hs.userService = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), + ) }) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -723,12 +716,11 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { - var err error sc := setupHTTPServer(t, true, func(hs *HTTPServer) { hs.tempUserService = tempuserimpl.ProvideService(hs.SQLStore) - hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), quotatest.New(false, nil)) - require.NoError(t, err) + hs.userService = userimpl.ProvideService( + hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), + ) }) setInitCtxSignedInViewer(sc.initCtx) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -843,14 +835,11 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl - var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - quotaService := quotatest.New(false, nil) - hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) - require.NoError(t, err) - hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) - require.NoError(t, err) + hs.userService = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), + ) + hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) @@ -973,14 +962,11 @@ func TestDeleteOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl - var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - quotaService := quotatest.New(false, nil) - hs.userService, err = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) - require.NoError(t, err) - hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) - require.NoError(t, err) + hs.userService = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), + ) + hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) diff --git a/pkg/api/plugin_dashboards_test.go b/pkg/api/plugin_dashboards_test.go index 6ad7abfcc95..e98116f96f4 100644 --- a/pkg/api/plugin_dashboards_test.go +++ b/pkg/api/plugin_dashboards_test.go @@ -41,7 +41,7 @@ func TestGetPluginDashboards(t *testing.T) { s := SetupAPITestServer(t, func(hs *HTTPServer) { hs.pluginDashboardService = pluginDashboardService - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) t.Run("Not signed in should return 404 Not Found", func(t *testing.T) { diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index af7ec30e5ac..e31a16c07c1 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -32,7 +32,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" @@ -139,9 +138,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -154,9 +151,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -168,9 +163,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -181,9 +174,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -197,9 +188,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -208,9 +197,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -220,9 +207,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = org.RoleAdmin - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -313,9 +298,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -331,9 +314,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -350,9 +331,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -376,9 +355,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -405,9 +382,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -433,9 +408,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -465,9 +438,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -492,9 +463,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -545,9 +514,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -684,9 +651,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -706,9 +671,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -724,9 +687,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -750,9 +711,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -779,9 +738,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -807,9 +764,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -835,11 +790,8 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - var err error - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) - _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) } @@ -860,10 +812,8 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) - _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) } @@ -906,9 +856,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) if tc.err == nil { require.NoError(t, err) @@ -936,9 +884,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -1055,9 +1001,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets tracer := tracing.InitializeTracerForTest() var routes []*plugins.Route - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -1101,9 +1045,7 @@ func Test_PathCheck(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) proxy, err := NewDataSourceProxy(&datasources.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 0afe045c0a8..ab9a5f977c2 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -60,7 +60,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled, } hs.pluginInstaller = inst - hs.QuotaService = quotatest.New(false, nil) + hs.QuotaService = quotatest.NewQuotaServiceFake() }) t.Run(testName("Install", tc), func(t *testing.T) { diff --git a/pkg/api/quota.go b/pkg/api/quota.go index dd0ee9f538d..9d3fa2a5c0b 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -6,22 +6,10 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) -// swagger:route GET /org/quotas getCurrentOrg getCurrentOrgQuota -// -// Fetch Organization quota. -// -// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope). -// -// Responses: -// 200: getQuotaResponse -// 401: unauthorisedError -// 403: forbiddenError -// 404: notFoundError -// 500: internalServerError func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Response { return hs.getOrgQuotasHelper(c, c.OrgID) } @@ -41,17 +29,22 @@ func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Respons func (hs *HTTPServer) GetOrgQuotas(c *models.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { - return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) + return response.Error(http.StatusBadRequest, "orgId is invalid", err) } return hs.getOrgQuotasHelper(c, orgId) } func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) response.Response { - q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.OrgScope, orgID) - if err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "failed to get quota", err) + if !hs.Cfg.Quota.Enabled { + return response.Error(404, "Quotas not enabled", nil) } - return response.JSON(http.StatusOK, q) + query := models.GetOrgQuotasQuery{OrgId: orgID} + + if err := hs.SQLStore.GetOrgQuotas(c.Req.Context(), &query); err != nil { + return response.Error(500, "Failed to get org quotas", err) + } + + return response.JSON(http.StatusOK, query.Result) } // swagger:route PUT /orgs/{org_id}/quotas/{quota_target} orgs updateOrgQuota @@ -70,19 +63,26 @@ func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) resp // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { - cmd := quota.UpdateQuotaCmd{} + cmd := models.UpdateOrgQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { - return response.Err(quota.ErrBadRequest.Errorf("bad request data: %w", err)) + return response.Error(http.StatusBadRequest, "bad request data", err) } - cmd.OrgID, err = strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) + if !hs.Cfg.Quota.Enabled { + return response.Error(404, "Quotas not enabled", nil) + } + cmd.OrgId, err = strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { - return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) + return response.Error(http.StatusBadRequest, "orgId is invalid", err) } cmd.Target = web.Params(c.Req)[":target"] - if err := hs.QuotaService.Update(c.Req.Context(), &cmd); err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update org quotas", err) + if _, ok := hs.Cfg.Quota.Org.ToMap()[cmd.Target]; !ok { + return response.Error(404, "Invalid quota target", nil) + } + + if err := hs.SQLStore.UpdateOrgQuota(c.Req.Context(), &cmd); err != nil { + return response.Error(500, "Failed to update org quotas", err) } return response.Success("Organization quota updated") } @@ -114,17 +114,22 @@ func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { + if !setting.Quota.Enabled { + return response.Error(404, "Quotas not enabled", nil) + } + id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { - return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) + return response.Error(http.StatusBadRequest, "id is invalid", err) } - q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.UserScope, id) - if err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "Failed to get org quotas", err) + query := models.GetUserQuotasQuery{UserId: id} + + if err := hs.SQLStore.GetUserQuotas(c.Req.Context(), &query); err != nil { + return response.Error(500, "Failed to get org quotas", err) } - return response.JSON(http.StatusOK, q) + return response.JSON(http.StatusOK, query.Result) } // swagger:route PUT /admin/users/{user_id}/quotas/{quota_target} admin_users updateUserQuota @@ -143,19 +148,26 @@ func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { - cmd := quota.UpdateQuotaCmd{} + cmd := models.UpdateUserQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { - return response.Err(quota.ErrBadRequest.Errorf("bad request data: %w", err)) + return response.Error(http.StatusBadRequest, "bad request data", err) } - cmd.UserID, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) + if !setting.Quota.Enabled { + return response.Error(404, "Quotas not enabled", nil) + } + cmd.UserId, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { - return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) + return response.Error(http.StatusBadRequest, "id is invalid", err) } cmd.Target = web.Params(c.Req)[":target"] - if err := hs.QuotaService.Update(c.Req.Context(), &cmd); err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update org quotas", err) + if _, ok := setting.Quota.User.ToMap()[cmd.Target]; !ok { + return response.Error(404, "Invalid quota target", nil) + } + + if err := hs.SQLStore.UpdateUserQuota(c.Req.Context(), &cmd); err != nil { + return response.Error(500, "Failed to update org quotas", err) } return response.Success("Organization quota updated") } @@ -164,7 +176,7 @@ func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { type UpdateUserQuotaParams struct { // in:body // required:true - Body quota.UpdateQuotaCmd `json:"body"` + Body models.UpdateUserQuotaCmd `json:"body"` // in:path // required:true QuotaTarget string `json:"quota_target"` @@ -191,7 +203,7 @@ type GetOrgQuotaParams struct { type UpdateOrgQuotaParam struct { // in:body // required:true - Body quota.UpdateQuotaCmd `json:"body"` + Body models.UpdateOrgQuotaCmd `json:"body"` // in:path // required:true QuotaTarget string `json:"quota_target"` @@ -203,5 +215,5 @@ type UpdateOrgQuotaParam struct { // swagger:response getQuotaResponse type GetQuotaResponseResponse struct { // in:body - Body []*quota.QuotaDTO `json:"body"` + Body []*models.UserQuotaDTO `json:"body"` } diff --git a/pkg/api/quota_test.go b/pkg/api/quota_test.go index 36e128f9124..51a6806a35f 100644 --- a/pkg/api/quota_test.go +++ b/pkg/api/quota_test.go @@ -32,13 +32,17 @@ var testOrgQuota = setting.OrgQuota{ func setupDBAndSettingsForAccessControlQuotaTests(t *testing.T, sc accessControlScenarioContext) { t.Helper() + sc.hs.Cfg.Quota.Enabled = true + sc.hs.Cfg.Quota.Org = &testOrgQuota + // Required while sqlstore quota.go relies on setting global variables + setting.Quota = sc.hs.Cfg.Quota + // Create two orgs with the context user setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) } func TestAPIEndpoint_GetCurrentOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() - cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -58,9 +62,7 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { - cfg := setting.NewCfg() - cfg.Quota.Enabled = true - sc := setupHTTPServerWithCfg(t, true, cfg) + sc := setupHTTPServer(t, true) setInitCtxSignedInViewer(sc.initCtx) setupDBAndSettingsForAccessControlQuotaTests(t, sc) @@ -84,7 +86,6 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { func TestAPIEndpoint_GetOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() - cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -104,9 +105,7 @@ func TestAPIEndpoint_GetOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { - cfg := setting.NewCfg() - cfg.Quota.Enabled = true - sc := setupHTTPServerWithCfg(t, true, cfg) + sc := setupHTTPServer(t, true) setupDBAndSettingsForAccessControlQuotaTests(t, sc) t.Run("AccessControl allows viewing another org quotas with correct permissions", func(t *testing.T) { @@ -131,7 +130,6 @@ func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { func TestAPIEndpoint_PutOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() - cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -153,20 +151,7 @@ func TestAPIEndpoint_PutOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_PutOrgQuotas_AccessControl(t *testing.T) { - cfg := setting.NewCfg() - cfg.Quota = setting.QuotaSettings{ - Enabled: true, - Global: setting.GlobalQuota{ - Org: 5, - }, - Org: setting.OrgQuota{ - User: 5, - }, - User: setting.UserQuota{ - Org: 5, - }, - } - sc := setupHTTPServerWithCfg(t, true, cfg) + sc := setupHTTPServer(t, true) setupDBAndSettingsForAccessControlQuotaTests(t, sc) input := strings.NewReader(testUpdateOrgQuotaCmd) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 5108aebabc9..0998a71687c 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -20,7 +20,6 @@ import ( acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/secrets/database" @@ -69,8 +68,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } user, err := sqlStore.CreateUser(context.Background(), createUserCmd) require.Nil(t, err) - hs.userService, err = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil, quotatest.New(false, nil)) - require.NoError(t, err) + hs.userService = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil) sc.handlerFunc = hs.GetUserByID diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 8d0d85fda7a..49819799069 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -254,7 +254,7 @@ var wireSet = wire.NewSet( wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, auth.ProvideActiveAuthTokenService, - wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + wire.Bind(new(models.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/middleware/quota.go b/pkg/middleware/quota.go index 7a0689ff11d..57533436ebe 100644 --- a/pkg/middleware/quota.go +++ b/pkg/middleware/quota.go @@ -14,15 +14,15 @@ func Quota(quotaService quota.Service) func(string) web.Handler { panic("quotaService is nil") } //https://open.spotify.com/track/7bZSoBEAEEUsGEuLOf94Jm?si=T1Tdju5qRSmmR0zph_6RBw fuuuuunky - return func(targetSrv string) web.Handler { + return func(target string) web.Handler { return func(c *models.ReqContext) { - limitReached, err := quotaService.QuotaReached(c, quota.TargetSrv(targetSrv)) + limitReached, err := quotaService.QuotaReached(c, target) if err != nil { c.JsonApiErr(500, "Failed to get quota", err) return } if limitReached { - c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", targetSrv), nil) + c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", target), nil) return } } diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 446b7842933..3f0aacd89ab 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -30,6 +30,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Global.User = 4 }) middlewareScenario(t, "and global session quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -39,6 +41,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Global.Session = 10 }) middlewareScenario(t, "and global session quota reached", func(t *testing.T, sc *scenarioContext) { @@ -48,10 +52,13 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Global.Session = 1 }) }) t.Run("with user logged in", func(t *testing.T) { + const quotaUsed = 4 setUp := func(sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{UserID: 12} @@ -72,6 +79,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Global.DataSource = quotaUsed }) middlewareScenario(t, "user Org quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -84,6 +93,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.User.Org = quotaUsed + 1 }) middlewareScenario(t, "user Org quota reached", func(t *testing.T, sc *scenarioContext) { @@ -95,6 +106,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.User.Org = quotaUsed }) middlewareScenario(t, "org dashboard quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -106,6 +119,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Org.Dashboard = quotaUsed + 1 }) middlewareScenario(t, "org dashboard quota reached", func(t *testing.T, sc *scenarioContext) { @@ -117,6 +132,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Org.Dashboard = quotaUsed }) middlewareScenario(t, "org dashboard quota reached, but quotas disabled", func(t *testing.T, sc *scenarioContext) { @@ -128,6 +145,9 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Org.Dashboard = quotaUsed + cfg.Quota.Enabled = false }) middlewareScenario(t, "org alert quota reached and unified alerting is enabled", func(t *testing.T, sc *scenarioContext) { @@ -142,6 +162,7 @@ func TestMiddlewareQuota(t *testing.T) { cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true + cfg.Quota.Org.AlertRule = quotaUsed }) middlewareScenario(t, "org alert quota not reached and unified alerting is enabled", func(t *testing.T, sc *scenarioContext) { @@ -156,6 +177,7 @@ func TestMiddlewareQuota(t *testing.T) { cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true + cfg.Quota.Org.AlertRule = quotaUsed + 1 }) middlewareScenario(t, "org alert quota reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { @@ -168,6 +190,8 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Org.AlertRule = quotaUsed }) middlewareScenario(t, "org alert quota not reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { @@ -179,15 +203,58 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) + + cfg.Quota.Org.AlertRule = quotaUsed + 1 }) }) } func getQuotaHandler(reached bool, target string) web.Handler { - qs := quotatest.New(reached, nil) + qs := &mockQuotaService{ + reached: reached, + } return Quota(qs)(target) } func configure(cfg *setting.Cfg) { cfg.AnonymousEnabled = false + cfg.Quota = setting.QuotaSettings{ + Enabled: true, + Org: &setting.OrgQuota{ + User: 5, + Dashboard: 5, + DataSource: 5, + ApiKey: 5, + AlertRule: 5, + }, + User: &setting.UserQuota{ + Org: 5, + }, + Global: &setting.GlobalQuota{ + Org: 5, + User: 5, + Dashboard: 5, + DataSource: 5, + ApiKey: 5, + Session: 5, + AlertRule: 5, + }, + } +} + +type mockQuotaService struct { + reached bool + err error +} + +func (m *mockQuotaService) QuotaReached(c *models.ReqContext, target string) (bool, error) { + return m.reached, m.err +} + +func (m *mockQuotaService) CheckQuotaReached(c context.Context, target string, params *quota.ScopeParameters) (bool, error) { + return m.reached, m.err +} + +func (m *mockQuotaService) DeleteByUser(c context.Context, userID int64) error { + return m.err } diff --git a/pkg/models/quotas.go b/pkg/models/quotas.go new file mode 100644 index 00000000000..26a63a92423 --- /dev/null +++ b/pkg/models/quotas.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "time" +) + +var ErrInvalidQuotaTarget = errors.New("invalid quota target") + +type Quota struct { + Id int64 + OrgId int64 + UserId int64 + Target string + Limit int64 + Created time.Time + Updated time.Time +} + +type QuotaScope struct { + Name string + Target string + DefaultLimit int64 +} + +type OrgQuotaDTO struct { + OrgId int64 `json:"org_id"` + Target string `json:"target"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` +} + +type UserQuotaDTO struct { + UserId int64 `json:"user_id"` + Target string `json:"target"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` +} + +type GlobalQuotaDTO struct { + Target string `json:"target"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` +} + +type GetOrgQuotaByTargetQuery struct { + Target string + OrgId int64 + Default int64 + UnifiedAlertingEnabled bool + Result *OrgQuotaDTO +} + +type GetOrgQuotasQuery struct { + OrgId int64 + UnifiedAlertingEnabled bool + Result []*OrgQuotaDTO +} + +type GetUserQuotaByTargetQuery struct { + Target string + UserId int64 + Default int64 + UnifiedAlertingEnabled bool + Result *UserQuotaDTO +} + +type GetUserQuotasQuery struct { + UserId int64 + UnifiedAlertingEnabled bool + Result []*UserQuotaDTO +} + +type GetGlobalQuotaByTargetQuery struct { + Target string + Default int64 + UnifiedAlertingEnabled bool + Result *GlobalQuotaDTO +} + +type UpdateOrgQuotaCmd struct { + Target string `json:"target"` + Limit int64 `json:"limit"` + OrgId int64 `json:"-"` +} + +type UpdateUserQuotaCmd struct { + Target string `json:"target"` + Limit int64 `json:"limit"` + UserId int64 `json:"-"` +} diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 6c92a40d86b..6ce74c004f3 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -76,6 +76,10 @@ type UserTokenService interface { GetUserRevokedTokens(ctx context.Context, userId int64) ([]*UserToken, error) } +type ActiveTokenService interface { + ActiveTokenCount(ctx context.Context) (int64, error) +} + type UserTokenBackgroundService interface { registry.BackgroundService } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 90fbdd0a9d8..1123c997893 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -272,7 +272,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, auth.ProvideActiveAuthTokenService, - wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + wire.Bind(new(models.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index c1352b89f9b..7c033d2f4a8 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -226,8 +225,7 @@ func setupTestEnvironment(t *testing.T, permissions []accesscontrol.Permission, sql := db.InitTestDB(t) cfg := setting.NewCfg() teamSvc := teamimpl.ProvideService(sql, cfg) - userSvc, err := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil, quotatest.New(false, nil)) - require.NoError(t, err) + userSvc := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil) license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() mock := accesscontrolmock.New().WithPermissions(permissions) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 44239e50f95..e7615243fdc 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -57,9 +56,7 @@ func TestIntegrationAnnotations(t *testing.T) { assert.NoError(t, err) }) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) testDashboard1 := models.SaveDashboardCommand{ UserId: 1, @@ -456,9 +453,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { var maximumTagsLength int64 = 60 repo := xormRepositoryImpl{db: sql, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(sql, sql.Cfg), maximumTagsLength: maximumTagsLength} - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) testDashboard1 := models.SaveDashboardCommand{ UserId: 1, diff --git a/pkg/services/apikey/apikeyimpl/apikey.go b/pkg/services/apikey/apikeyimpl/apikey.go index 2a09d26319f..4b2af715707 100644 --- a/pkg/services/apikey/apikeyimpl/apikey.go +++ b/pkg/services/apikey/apikeyimpl/apikey.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" ) @@ -14,34 +13,16 @@ type Service struct { store store } -func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (apikey.Service, error) { - s := &Service{} +func ProvideService(db db.DB, cfg *setting.Cfg) apikey.Service { if cfg.IsFeatureToggleEnabled(featuremgmt.FlagNewDBLibrary) { - s.store = &sqlxStore{ - sess: db.GetSqlxSession(), - cfg: cfg, + return &Service{ + store: &sqlxStore{ + sess: db.GetSqlxSession(), + cfg: cfg, + }, } } - s.store = &sqlStore{db: db, cfg: cfg} - - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return s, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: apikey.QuotaTargetSrv, - DefaultLimits: defaultLimits, - Reporter: s.Usage, - }); err != nil { - return s, err - } - - return s, nil -} - -func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - return s.store.Count(ctx, scopeParams) + return &Service{store: &sqlStore{db: db, cfg: cfg}} } func (s *Service) GetAPIKeys(ctx context.Context, query *apikey.GetApiKeysQuery) error { @@ -68,24 +49,3 @@ func (s *Service) AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error { func (s *Service) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error { return s.store.UpdateAPIKeyLastUsedDate(ctx, tokenID) } - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) - if err != nil { - return limits, err - } - orgQuotaTag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.ApiKey) - limits.Set(orgQuotaTag, cfg.Quota.Org.ApiKey) - return limits, nil -} diff --git a/pkg/services/apikey/apikeyimpl/sqlx_store.go b/pkg/services/apikey/apikeyimpl/sqlx_store.go index b9935a58123..9401a975931 100644 --- a/pkg/services/apikey/apikeyimpl/sqlx_store.go +++ b/pkg/services/apikey/apikeyimpl/sqlx_store.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/setting" ) @@ -143,35 +142,3 @@ func (ss *sqlxStore) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64 _, err := ss.sess.Exec(ctx, `UPDATE api_key SET last_used_at=? WHERE id=?`, &now, tokenID) return err } - -func (ss *sqlxStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - type result struct { - Count int64 - } - - r := result{} - if err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key`); err != nil { - return u, err - } else { - tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) - if err != nil { - return nil, err - } - u.Set(tag, r.Count) - } - - if scopeParams.OrgID != 0 { - if err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key WHERE org_id = ?`, scopeParams.OrgID); err != nil { - return u, err - } else { - tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) - if err != nil { - return nil, err - } - u.Set(tag, r.Count) - } - } - - return u, nil -} diff --git a/pkg/services/apikey/apikeyimpl/store.go b/pkg/services/apikey/apikeyimpl/store.go index 54988660d08..33b8159e7cc 100644 --- a/pkg/services/apikey/apikeyimpl/store.go +++ b/pkg/services/apikey/apikeyimpl/store.go @@ -4,7 +4,6 @@ import ( "context" "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/quota" ) type store interface { @@ -16,6 +15,4 @@ type store interface { GetApiKeyByName(ctx context.Context, query *apikey.GetByNameQuery) error GetAPIKeyByHash(ctx context.Context, hash string) (*apikey.APIKey, error) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error - - Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } diff --git a/pkg/services/apikey/apikeyimpl/xorm_store.go b/pkg/services/apikey/apikeyimpl/xorm_store.go index bf2ba4ce6d4..fad3bb89401 100644 --- a/pkg/services/apikey/apikeyimpl/xorm_store.go +++ b/pkg/services/apikey/apikeyimpl/xorm_store.go @@ -11,8 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -176,47 +174,3 @@ func (ss *sqlStore) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) return nil }) } - -func (ss *sqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - type result struct { - Count int64 - } - - r := result{} - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := "SELECT COUNT(*) AS count FROM api_key" - if _, err := sess.SQL(rawSQL).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) - if err != nil { - return nil, err - } - u.Set(tag, r.Count) - } - - if scopeParams.OrgID != 0 { - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := "SELECT COUNT(*) AS count FROM api_key WHERE org_id = ?" - if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) - if err != nil { - return nil, err - } - u.Set(tag, r.Count) - } - } - - return u, nil -} diff --git a/pkg/services/apikey/model.go b/pkg/services/apikey/model.go index 9563377760b..82acaf3b77e 100644 --- a/pkg/services/apikey/model.go +++ b/pkg/services/apikey/model.go @@ -5,7 +5,6 @@ import ( "time" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -65,8 +64,3 @@ type GetByIDQuery struct { ApiKeyId int64 Result *APIKey } - -const ( - QuotaTargetSrv quota.TargetSrv = "api_key" - QuotaTarget quota.Target = "api_key" -) diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index f261e33bcd1..dfbd80c8064 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -42,38 +41,19 @@ type UserAuthTokenService struct { log log.Logger } -type ActiveTokenService interface { - ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) -} - type ActiveAuthTokenService struct { cfg *setting.Cfg sqlStore db.DB } -func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaService quota.Service) (*ActiveAuthTokenService, error) { - s := &ActiveAuthTokenService{ +func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB) *ActiveAuthTokenService { + return &ActiveAuthTokenService{ cfg: cfg, sqlStore: sqlStore, } - - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return s, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: QuotaTargetSrv, - DefaultLimits: defaultLimits, - Reporter: s.ActiveTokenCount, - }); err != nil { - return s, err - } - - return s, nil } -func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { +func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, error) { var count int64 var err error err = a.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { @@ -86,14 +66,7 @@ func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota. return err }) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - if err != nil { - return nil, err - } - u := "a.Map{} - u.Set(tag, count) - - return u, err + return count, err } func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { @@ -499,19 +472,3 @@ func hashToken(token string) string { hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) return hex.EncodeToString(hashBytes[:]) } - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.Session) - return limits, nil -} diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 16886d7b439..a2e86b79e42 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -41,12 +40,8 @@ func TestUserAuthToken(t *testing.T) { userToken := createToken() t.Run("Can count active tokens", func(t *testing.T) { - m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) + count, err := ctx.activeTokenService.ActiveTokenCount(context.Background()) require.Nil(t, err) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - require.NoError(t, err) - count, ok := m.Get(tag) - require.True(t, ok) require.Equal(t, int64(1), count) }) @@ -213,12 +208,8 @@ func TestUserAuthToken(t *testing.T) { require.Nil(t, notGood) t.Run("should not find active token when expired", func(t *testing.T) { - m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) + count, err := ctx.activeTokenService.ActiveTokenCount(context.Background()) require.Nil(t, err) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - require.NoError(t, err) - count, ok := m.Get(tag) - require.True(t, ok) require.Equal(t, int64(0), count) }) }) diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go index afc5b566c48..799b3e68b16 100644 --- a/pkg/services/auth/model.go +++ b/pkg/services/auth/model.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" ) type userAuthToken struct { @@ -72,8 +71,3 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { return nil } - -const ( - QuotaTargetSrv quota.TargetSrv = "auth" - QuotaTarget quota.Target = "session" -) diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index f491d645bdc..12691f8ed5e 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) @@ -65,9 +64,9 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re return response.Error(http.StatusUnprocessableEntity, "Dashboard must be set", nil) } - limitReached, err := api.quotaService.QuotaReached(c, dashboards.QuotaTargetSrv) + limitReached, err := api.quotaService.QuotaReached(c, "dashboard") if err != nil { - return response.Err(err) + return response.Error(500, "failed to get quota", err) } if limitReached { @@ -84,12 +83,12 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re } type QuotaService interface { - QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) + QuotaReached(c *models.ReqContext, target string) (bool, error) } -type quotaServiceFunc func(c *models.ReqContext, target quota.TargetSrv) (bool, error) +type quotaServiceFunc func(c *models.ReqContext, target string) (bool, error) -func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target string) (bool, error) { return fn(c, target) } diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index d688e019109..77085c0c01a 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboardimport" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web/webtest" "github.com/stretchr/testify/require" @@ -166,10 +165,10 @@ func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport. return nil, nil } -func quotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func quotaReached(c *models.ReqContext, target string) (bool, error) { return true, nil } -func quotaNotReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func quotaNotReached(c *models.ReqContext, target string) (bool, error) { return false, nil } diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 78428d39700..82f4eaa0850 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -4,7 +4,6 @@ import ( "context" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" ) // DashboardService is a service for operating on dashboards. @@ -78,7 +77,6 @@ type Store interface { ValidateDashboardBeforeSave(ctx context.Context, dashboard *models.Dashboard, overwrite bool) (bool, error) DeleteACLByUser(context.Context, int64) error - Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) // CountDashboardsInFolder returns the number of dashboards associated with // the given parent folder ID. CountDashboardsInFolder(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) diff --git a/pkg/services/dashboards/database/acl_test.go b/pkg/services/dashboards/database/acl_test.go index 86ef2df3e3a..3836bfdb01e 100644 --- a/pkg/services/dashboards/database/acl_test.go +++ b/pkg/services/dashboards/database/acl_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -27,10 +26,7 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { setup := func(t *testing.T) { sqlStore = db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") childDash = insertTestDashboard(t, dashboardStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp") diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 2cfc1d0299e..321e7eab7c0 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -16,8 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -44,23 +42,8 @@ type DashboardTag struct { // DashboardStore implements the Store interface var _ dashboards.Store = (*DashboardStore)(nil) -func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (*DashboardStore, error) { - s := &DashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} - - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return nil, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: dashboards.QuotaTargetSrv, - DefaultLimits: defaultLimits, - Reporter: s.Count, - }); err != nil { - return nil, err - } - - return s, nil +func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service) *DashboardStore { + return &DashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} } func (d *DashboardStore) emitEntityEvent() bool { @@ -308,50 +291,6 @@ func (d *DashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context }) } -func (d *DashboardStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - type result struct { - Count int64 - } - - r := result{} - if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.GetDialect().BooleanStr(false)) - if _, err := sess.SQL(rawSQL).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) - if err != nil { - return nil, err - } - u.Set(tag, r.Count) - } - - if scopeParams.OrgID != 0 { - if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.GetDialect().BooleanStr(false)) - if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) - if err != nil { - return nil, err - } - u.Set(tag, r.Count) - } - } - - return u, nil -} - func getExistingDashboardByIdOrUidForUpdate(sess *db.Session, dash *models.Dashboard, dialect migrator.Dialect, overwrite bool) (bool, error) { dashWithIdExists := false isParentFolderChanged := false @@ -1079,27 +1018,6 @@ func (d *DashboardStore) GetDashboardTags(ctx context.Context, query *models.Get }) } -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) - if err != nil { - return "a.Map{}, err - } - orgQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) - if err != nil { - return "a.Map{}, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.Dashboard) - limits.Set(orgQuotaTag, cfg.Quota.Org.Dashboard) - return limits, nil -} - // This will be updated to take CountDashboardsInFolderQuery as an argument and // lookup dashboards using the ParentFolderUID when the NestedFolder // implementation is complete. diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 97b12665d52..8f104a670ff 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -34,10 +33,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup := func() { sqlStore = db.InitTestDB(t) sqlStore.Cfg.RBACEnabled = false - quotaService := quotatest.New(false, nil) - var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp") childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, folder.Id, false, "prod", "webapp") @@ -190,9 +186,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2 := func() { sqlStore = db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod") @@ -297,9 +291,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup3 := func() { sqlStore = db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") insertTestDashboard(t, dashboardStore, "folder in another org", 2, 0, true, "prod") @@ -481,9 +473,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { var sqlStore *sqlstore.SQLStore var folder1, folder2 *models.Dashboard sqlStore = db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folder2 = insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") _ = insertTestDashboard(t, dashboardStore, title, orgId, folder2.Id, false, "prod") folder1 = insertTestDashboard(t, dashboardStore, title, orgId, 0, true, "prod") @@ -498,9 +488,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByUID", func(t *testing.T) { var orgId int64 = 1 sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folder := insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.Id, false, "prod") @@ -524,9 +512,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByID", func(t *testing.T) { var orgId int64 = 1 sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folder := insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.Id, false, "prod") diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index 2bfd0feb0cf..35e7d8e18de 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" ) @@ -19,9 +18,7 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { t.Skip("skipping integration test") } sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) folderCmd := models.SaveDashboardCommand{ OrgId: 1, diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 4f63c4aef3d..5163e0d8d90 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/publicdashboards/database" publicDashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/star" @@ -43,10 +42,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) starService = starimpl.ProvideService(sqlStore, cfg) - quotaService := quotatest.New(false, nil) - var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp") insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.Id, false, "prod") @@ -589,9 +585,7 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) pluginId := "test-app" appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId) @@ -603,7 +597,7 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test OrgId: 1, } - err = dashboardStore.GetDashboardsByPluginID(context.Background(), &query) + err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) } @@ -615,9 +609,7 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) dashA := insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) @@ -668,9 +660,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - quotaService := quotatest.New(false, nil) - dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) qNoFilter := &models.FindPersistedDashboardsQuery{ diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 4c88e2669db..21c184cff5c 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -4,7 +4,6 @@ import ( "time" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -31,11 +30,6 @@ type DashboardSearchProjection struct { SortMeta int64 } -const ( - QuotaTargetSrv quota.TargetSrv = "dashboard" - QuotaTarget quota.Target = "dashboard" -) - type CountDashboardsInFolderQuery struct { FolderUID string } diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 210085b565d..5c5c369d864 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -43,7 +42,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { }), } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardNotFound, err) }) @@ -63,7 +62,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: false, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardNotFound, err) }) @@ -105,7 +104,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sqlStore) + err := callSaveWithError(cmd, sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, int64(0), sc.dashboardGuardianMock.DashId) @@ -125,7 +124,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.otherSavedFolder.Id, sc.dashboardGuardianMock.DashId) @@ -145,7 +144,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -166,7 +165,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -187,7 +186,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -208,7 +207,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -229,7 +228,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -250,7 +249,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -271,7 +270,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -292,7 +291,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -433,7 +432,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardFolderNotFound, err) }) @@ -449,7 +448,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) }) @@ -489,7 +488,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) }) @@ -528,7 +527,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) @@ -544,7 +543,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) @@ -560,7 +559,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) }) @@ -648,7 +647,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err) }) @@ -712,7 +711,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -728,7 +727,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -744,7 +743,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -760,7 +759,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -775,7 +774,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameAsFolder, err) }) @@ -790,7 +789,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(t, cmd, sc.sqlStore) + err := callSaveWithError(cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardFolderWithSameNameAsDashboard, err) }) }) @@ -822,9 +821,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -881,9 +878,7 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -897,14 +892,12 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore return res } -func callSaveWithError(t *testing.T, cmd models.SaveDashboardCommand, sqlStore db.DB) error { +func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore db.DB) error { dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -912,7 +905,7 @@ func callSaveWithError(t *testing.T, cmd models.SaveDashboardCommand, sqlStore d accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), ) - _, err = service.SaveDashboard(context.Background(), &dto, false) + _, err := service.SaveDashboard(context.Background(), &dto, false) return err } @@ -941,9 +934,7 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -981,9 +972,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *mo cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 2bd9ff1284c..5824d5332db 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -6,7 +6,6 @@ import ( context "context" models "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" ) @@ -474,10 +473,6 @@ type mockConstructorTestingTNewFakeDashboardStore interface { Cleanup(func()) } -func (_m *FakeDashboardStore) Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) { - return nil, nil -} - // NewFakeDashboardStore creates a new instance of FakeDashboardStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. func NewFakeDashboardStore(t mockConstructorTestingTNewFakeDashboardStore) *FakeDashboardStore { mock := &FakeDashboardStore{} diff --git a/pkg/services/datasources/models.go b/pkg/services/datasources/models.go index fec4db4ade7..9697c739cbc 100644 --- a/pkg/services/datasources/models.go +++ b/pkg/services/datasources/models.go @@ -4,7 +4,6 @@ import ( "time" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -194,8 +193,3 @@ type DatasourcesPermissionFilterQuery struct { Datasources []*DataSource Result []*DataSource } - -const ( - QuotaTargetSrv quota.TargetSrv = "data_source" - QuotaTarget quota.Target = "data_source" -) diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 3b4bb78e01e..064a3431029 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/setting" @@ -53,8 +52,7 @@ type cachedRoundTripper struct { func ProvideService( db db.DB, secretsService secrets.Service, secretsStore kvstore.SecretsKVStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService, - quotaService quota.Service, -) (*Service, error) { +) *Service { dslogger := log.New("datasources") store := &SqlStore{db: db, logger: dslogger} s := &Service{ @@ -75,23 +73,7 @@ func ProvideService( ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store)) ac.RegisterScopeAttributeResolver(NewIDScopeResolver(store)) - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return nil, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: datasources.QuotaTargetSrv, - DefaultLimits: defaultLimits, - Reporter: s.Usage, - }); err != nil { - return nil, err - } - return s, nil -} - -func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - return s.SQLStore.Count(ctx, scopeParams) + return s } // DataSourceRetriever interface for retrieving a datasource. @@ -609,24 +591,3 @@ func (s *Service) fillWithSecureJSONData(ctx context.Context, cmd *datasources.U return nil } - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.GlobalScope) - if err != nil { - return limits, err - } - orgQuotaTag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.OrgScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.DataSource) - limits.Set(orgQuotaTag, cfg.Quota.Org.DataSource) - return limits, nil -} diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index b06cb9913fa..e12e4c3ac56 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -21,7 +21,6 @@ import ( acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" @@ -201,9 +200,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) require.NoError(t, err) @@ -238,9 +235,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Id: 1, @@ -289,9 +284,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Id: 1, @@ -337,9 +330,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Id: 1, @@ -382,9 +373,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Id: 1, @@ -417,9 +406,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Id: 1, @@ -486,9 +473,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Id: 1, Url: "http://k8s:8001", @@ -522,9 +507,7 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) ds := datasources.DataSource{ Type: datasources.DS_ES, @@ -561,9 +544,7 @@ func TestService_getTimeout(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) for _, tc := range testCases { ds := &datasources.DataSource{ @@ -584,9 +565,7 @@ func TestService_GetDecryptedValues(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) jsonData := map[string]string{ "password": "securePassword", @@ -612,9 +591,7 @@ func TestService_GetDecryptedValues(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) jsonData := map[string]string{ "password": "securePassword", diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index 9737da64622..2074889ce64 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -16,8 +16,6 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" ) @@ -31,8 +29,6 @@ type Store interface { AddDataSource(context.Context, *datasources.AddDataSourceCommand) error UpdateDataSource(context.Context, *datasources.UpdateDataSourceCommand) error GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) error - - Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } type SqlStore struct { @@ -175,50 +171,6 @@ func (ss *SqlStore) DeleteDataSource(ctx context.Context, cmd *datasources.Delet }) } -func (ss *SqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - type result struct { - Count int64 - } - - r := result{} - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := "SELECT COUNT(*) AS count FROM data_source" - if _, err := sess.SQL(rawSQL).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.GlobalScope) - if err != nil { - return u, err - } - u.Set(tag, r.Count) - } - - if scopeParams.OrgID != 0 { - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := "SELECT COUNT(*) AS count FROM data_source WHERE org_id=?" - if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.OrgScope) - if err != nil { - return u, err - } - u.Set(tag, r.Count) - } - } - - return u, nil -} - func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataSourceCommand) error { return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { existing := datasources.DataSource{OrgId: cmd.OrgId, Name: cmd.Name} diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index d79a838535a..85c1dfaf04e 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" "github.com/stretchr/testify/assert" @@ -584,8 +583,7 @@ func TestIntegrationGetChildren(t *testing.T) { func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { t.Helper() - orgService, err := orgimpl.ProvideService(db, db.Cfg, quotatest.New(false, nil)) - require.NoError(t, err) + orgService := orgimpl.ProvideService(db, db.Cfg) orgID, err := orgService.GetOrCreate(context.Background(), "test-org") require.NoError(t, err) t.Cleanup(func() { diff --git a/pkg/services/guardian/accesscontrol_guardian_test.go b/pkg/services/guardian/accesscontrol_guardian_test.go index 39c0496a19c..8660e1cf2b5 100644 --- a/pkg/services/guardian/accesscontrol_guardian_test.go +++ b/pkg/services/guardian/accesscontrol_guardian_test.go @@ -19,7 +19,6 @@ import ( dashdb "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" @@ -592,9 +591,7 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce toSave.SetUid(uid) // seed dashboard - quotaService := quotatest.New(false, nil) - dashStore, err := dashdb.ProvideDashboardStore(store, store.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(store, store.Cfg), quotaService) - require.NoError(t, err) + dashStore := dashdb.ProvideDashboardStore(store, store.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(store, store.Cfg)) dash, err := dashStore.SaveDashboard(context.Background(), models.SaveDashboardCommand{ Dashboard: toSave.Data, UserId: 1, @@ -606,8 +603,7 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() teamSvc := teamimpl.ProvideService(store, store.Cfg) - userSvc, err := userimpl.ProvideService(store, nil, store.Cfg, nil, nil, quotatest.New(false, nil)) - require.NoError(t, err) + userSvc := userimpl.ProvideService(store, nil, store.Cfg, nil, nil) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( setting.NewCfg(), routing.NewRouteRegister(), store, ac, license, &dashboards.FakeDashboardStore{}, ac, teamSvc, userSvc) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 8ecc4399794..3b6a3975ed5 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -28,7 +28,6 @@ import ( "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -279,9 +278,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash cfg.RBACEnabled = false features := featuremgmt.WithFeatures() cfg.IsFeatureToggleEnabled = features.IsEnabled - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := acmock.New() folderPermissions := acmock.NewMockedPermissionsService() @@ -307,9 +304,7 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.S ac := acmock.New() folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) d := dashboardservice.ProvideDashboardService( cfg, dashboardStore, nil, @@ -410,9 +405,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := org.RoleAdmin sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) features := featuremgmt.WithFeatures() ac := acmock.New().WithDisabled() // TODO: Update tests to work with rbac @@ -449,7 +442,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Login: userInDbName, } - _, err = sqlStore.CreateUser(context.Background(), cmd) + _, err := sqlStore.CreateUser(context.Background(), cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index e4ca222f89d..a422dc729a2 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -692,9 +691,7 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := acmock.New() service := dashboardservice.ProvideDashboardService( @@ -718,9 +715,7 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user. features := featuremgmt.WithFeatures() folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) d := dashboardservice.ProvideDashboardService(cfg, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac) s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, features, folderPermissions, nil) @@ -813,9 +808,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := org.RoleAdmin sqlStore, cfg := db.InitTestDBwithCfg(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) features := featuremgmt.WithFeatures() ac := acmock.New() @@ -854,7 +847,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Login: userInDbName, } - _, err = sqlStore.CreateUser(context.Background(), cmd) + _, err := sqlStore.CreateUser(context.Background(), cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 1c28ac1423c..6e68e00c0a5 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -71,17 +71,13 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser return login.ErrSignupNotAllowed } - // we may insert in both user and org_user tables - // therefore we need to query check quota for both user and org services - for _, srv := range []string{user.QuotaTargetSrv, org.QuotaTargetSrv} { - limitReached, errLimit := ls.QuotaService.QuotaReached(cmd.ReqContext, quota.TargetSrv(srv)) - if errLimit != nil { - cmd.ReqContext.Logger.Warn("Error getting user quota.", "error", errLimit) - return login.ErrGettingUserQuota - } - if limitReached { - return login.ErrUsersQuotaReached - } + limitReached, errLimit := ls.QuotaService.QuotaReached(cmd.ReqContext, "user") + if errLimit != nil { + cmd.ReqContext.Logger.Warn("Error getting user quota.", "error", errLimit) + return login.ErrGettingUserQuota + } + if limitReached { + return login.ErrUsersQuotaReached } result, errCreateUser := ls.createUser(extUser) diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index edd9bade8d6..2655a7d5c3a 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" - "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/stretchr/testify/assert" @@ -26,7 +26,7 @@ func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{}, AuthInfoService: authInfoMock, SQLStore: nil, userService: usertest.NewUserServiceFake(), @@ -51,7 +51,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { orgService.ExpectedOrgListResponse = createResponseWithOneErrLastOrgAdminItem() login := Implementation{ - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{}, AuthInfoService: authInfoMock, SQLStore: nil, userService: usertest.NewUserServiceFake(), @@ -66,7 +66,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { func Test_teamSync(t *testing.T) { authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - QuotaService: quotatest.New(false, nil), + QuotaService: "aimpl.Service{}, AuthInfoService: authInfoMock, } diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index ffca5f0c293..bf0c3953c8f 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -145,28 +145,3 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { alertRules: api.AlertRules, }), m) } - -func (api *API) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - if orgUsage, err := api.RuleStore.Count(ctx, scopeParams.OrgID); err != nil { - return u, err - } else { - tag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.OrgScope) - if err != nil { - return u, err - } - u.Set(tag, orgUsage) - } - - if globalUsage, err := api.RuleStore.Count(ctx, 0); err != nil { - return u, err - } else { - tag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) - if err != nil { - return u, err - } - u.Set(tag, globalUsage) - } - - return u, nil -} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 433d3160ae5..9229bd40f78 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -393,7 +393,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod } if len(finalChanges.New) > 0 { - limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, ngmodels.QuotaTargetSrv, "a.ScopeParameters{ + limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{ OrgID: c.OrgID, UserID: c.UserID, }) // alert rule is table name diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 60341ae2594..bb8f59c7412 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -23,6 +23,4 @@ type RuleStore interface { // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) - - Count(ctx context.Context, orgID int64) (int64, error) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 83ee27f2a74..1572cd7b850 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -12,7 +12,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -461,11 +460,6 @@ func (g RulesGroup) SortByGroupIndex() { }) } -const ( - QuotaTargetSrv quota.TargetSrv = "ngalert" - QuotaTarget quota.Target = "alert_rule" -) - type ruleKeyContextKey struct{} func WithRuleKey(ctx context.Context, ruleKey AlertRuleKey) context.Context { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 12cc93d6ca2..d61b07fe9c0 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -239,19 +239,6 @@ func (ng *AlertNG) init() error { } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) - defaultLimits, err := readQuotaConfig(ng.Cfg) - if err != nil { - return err - } - - if err := ng.QuotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: models.QuotaTargetSrv, - DefaultLimits: defaultLimits, - Reporter: api.Usage, - }); err != nil { - return err - } - log.RegisterContextualLogProvider(func(ctx context.Context) ([]interface{}, bool) { key, ok := models.RuleKeyFromContext(ctx) if !ok { @@ -321,32 +308,3 @@ func (ng *AlertNG) IsDisabled() bool { } return !ng.Cfg.UnifiedAlerting.IsEnabled() } - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - var alertOrgQuota int64 - var alertGlobalQuota int64 - - if cfg.UnifiedAlerting.IsEnabled() { - alertOrgQuota = cfg.Quota.Org.AlertRule - alertGlobalQuota = cfg.Quota.Global.AlertRule - } - - globalQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) - if err != nil { - return limits, err - } - orgQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.OrgScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, alertGlobalQuota) - limits.Set(orgQuotaTag, alertOrgQuota) - return limits, nil -} diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index bfbbadb3646..97d406a8214 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -48,7 +48,7 @@ type RuleStore interface { // //go:generate mockery --name QuotaChecker --structname MockQuotaChecker --inpackage --filename quota_checker_mock.go --with-expecter type QuotaChecker interface { - CheckQuotaReached(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) + CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) } // PersistConfig validates to config before eventually persisting it if no error occurs diff --git a/pkg/services/ngalert/provisioning/quota_checker_mock.go b/pkg/services/ngalert/provisioning/quota_checker_mock.go index 1dac163c33d..f545dd1b5ec 100644 --- a/pkg/services/ngalert/provisioning/quota_checker_mock.go +++ b/pkg/services/ngalert/provisioning/quota_checker_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.12.0. DO NOT EDIT. package provisioning @@ -7,6 +7,8 @@ import ( quota "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" + + testing "testing" ) // MockQuotaChecker is an autogenerated mock type for the QuotaChecker type @@ -23,18 +25,18 @@ func (_m *MockQuotaChecker) EXPECT() *MockQuotaChecker_Expecter { } // CheckQuotaReached provides a mock function with given fields: ctx, target, scopeParams -func (_m *MockQuotaChecker) CheckQuotaReached(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { +func (_m *MockQuotaChecker) CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) { ret := _m.Called(ctx, target, scopeParams) var r0 bool - if rf, ok := ret.Get(0).(func(context.Context, quota.TargetSrv, *quota.ScopeParameters) bool); ok { + if rf, ok := ret.Get(0).(func(context.Context, string, *quota.ScopeParameters) bool); ok { r0 = rf(ctx, target, scopeParams) } else { r0 = ret.Get(0).(bool) } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, quota.TargetSrv, *quota.ScopeParameters) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, string, *quota.ScopeParameters) error); ok { r1 = rf(ctx, target, scopeParams) } else { r1 = ret.Error(1) @@ -49,16 +51,16 @@ type MockQuotaChecker_CheckQuotaReached_Call struct { } // CheckQuotaReached is a helper method to define mock.On call -// - ctx context.Context -// - target quota.TargetSrv -// - scopeParams *quota.ScopeParameters +// - ctx context.Context +// - target string +// - scopeParams *quota.ScopeParameters func (_e *MockQuotaChecker_Expecter) CheckQuotaReached(ctx interface{}, target interface{}, scopeParams interface{}) *MockQuotaChecker_CheckQuotaReached_Call { return &MockQuotaChecker_CheckQuotaReached_Call{Call: _e.mock.On("CheckQuotaReached", ctx, target, scopeParams)} } -func (_c *MockQuotaChecker_CheckQuotaReached_Call) Run(run func(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters)) *MockQuotaChecker_CheckQuotaReached_Call { +func (_c *MockQuotaChecker_CheckQuotaReached_Call) Run(run func(ctx context.Context, target string, scopeParams *quota.ScopeParameters)) *MockQuotaChecker_CheckQuotaReached_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(quota.TargetSrv), args[2].(*quota.ScopeParameters)) + run(args[0].(context.Context), args[1].(string), args[2].(*quota.ScopeParameters)) }) return _c } @@ -68,13 +70,8 @@ func (_c *MockQuotaChecker_CheckQuotaReached_Call) Return(_a0 bool, _a1 error) * return _c } -type mockConstructorTestingTNewMockQuotaChecker interface { - mock.TestingT - Cleanup(func()) -} - -// NewMockQuotaChecker creates a new instance of MockQuotaChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockQuotaChecker(t mockConstructorTestingTNewMockQuotaChecker) *MockQuotaChecker { +// NewMockQuotaChecker creates a new instance of MockQuotaChecker. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockQuotaChecker(t testing.TB) *MockQuotaChecker { mock := &MockQuotaChecker{} mock.Mock.Test(t) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 3a9cbac848d..1d2084857e3 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -269,29 +268,6 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR }) } -// Count returns either the number of the alert rules under a specific org (if orgID is not zero) -// or the number of all the alert rules -func (st DBstore) Count(ctx context.Context, orgID int64) (int64, error) { - type result struct { - Count int64 - } - - r := result{} - err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := "SELECT COUNT(*) as count from alert_rule" - args := make([]interface{}, 0) - if orgID != 0 { - rawSQL += " WHERE org_id=?" - args = append(args, orgID) - } - if _, err := sess.SQL(rawSQL, args...).Get(&r); err != nil { - return err - } - return nil - }) - return r.Count, err -} - func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) { var interval int64 = 0 return interval, st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index c2139c5d2bf..3cb2f1b7210 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -339,7 +339,3 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org } return result, nil } - -func (f *RuleStore) Count(ctx context.Context, orgID int64) (int64, error) { - return 0, nil -} diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index d8083bdb2e7..6862ed83361 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -31,7 +31,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -76,9 +75,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := db.InitTestDB(tb) secretsService := secretsManager.SetupTestService(tb, database.ProvideSecretsStore(sqlStore)) - quotaService := quotatest.New(false, nil) - dashboardStore, err := databasestore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(tb, err) + dashboardStore := databasestore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) ac := acmock.New() features := featuremgmt.WithFeatures() @@ -95,7 +92,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, folderService := folderimpl.ProvideService(ac, bus, cfg, dashboardService, dashboardStore, features, folderPermissions, nil) ng, err := ngalert.ProvideService( - cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), + cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil, secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, annotationstest.NewFakeAnnotationsRepo(), ) require.NoError(tb, err) diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index d6956ce2384..4dc0e2a0a2b 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -204,9 +204,3 @@ func (o ByOrgName) Less(i, j int) bool { return o[i].Name < o[j].Name } - -const ( - QuotaTargetSrv string = "org" - OrgQuotaTarget string = "org" - OrgUserQuotaTarget string = "org_user" -) diff --git a/pkg/services/org/orgimpl/org.go b/pkg/services/org/orgimpl/org.go index ed6c3bc506a..ca5539eb9a5 100644 --- a/pkg/services/org/orgimpl/org.go +++ b/pkg/services/org/orgimpl/org.go @@ -8,7 +8,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -19,9 +18,9 @@ type Service struct { log log.Logger } -func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (org.Service, error) { +func ProvideService(db db.DB, cfg *setting.Cfg) org.Service { log := log.New("org service") - s := &Service{ + return &Service{ store: &sqlStore{ db: db, dialect: db.GetDialect(), @@ -31,24 +30,6 @@ func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (org cfg: cfg, log: log, } - - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return s, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: quota.TargetSrv(org.QuotaTargetSrv), - DefaultLimits: defaultLimits, - Reporter: s.Usage, - }); err != nil { - return s, nil - } - return s, nil -} - -func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - return s.store.Count(ctx, scopeParams) } func (s *Service) GetIDForNewUser(ctx context.Context, cmd org.GetOrgIDForNewUserCommand) (int64, error) { @@ -198,31 +179,3 @@ func (s *Service) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) func (s *Service) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { return s.store.SearchOrgUsers(ctx, query) } - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope) - if err != nil { - return limits, err - } - orgQuotaTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope) - if err != nil { - return limits, err - } - userTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.Org) - // users per org - limits.Set(orgQuotaTag, cfg.Quota.Org.User) - // orgs per user - limits.Set(userTag, cfg.Quota.User.Org) - return limits, nil -} diff --git a/pkg/services/org/orgimpl/org_test.go b/pkg/services/org/orgimpl/org_test.go index 410bbf5a255..9d9b48c862c 100644 --- a/pkg/services/org/orgimpl/org_test.go +++ b/pkg/services/org/orgimpl/org_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -136,7 +135,3 @@ func (f *FakeOrgStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgU func (f *FakeOrgStore) RemoveOrgUser(ctx context.Context, cmd *org.RemoveOrgUserCommand) error { return f.ExpectedError } - -func (f *FakeOrgStore) Count(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { - return nil, nil -} diff --git a/pkg/services/org/orgimpl/store.go b/pkg/services/org/orgimpl/store.go index 09936a3195d..afc900223b5 100644 --- a/pkg/services/org/orgimpl/store.go +++ b/pkg/services/org/orgimpl/store.go @@ -14,8 +14,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -44,8 +42,6 @@ type store interface { GetByName(context.Context, *org.GetOrgByNameQuery) (*org.Org, error) SearchOrgUsers(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) RemoveOrgUser(context.Context, *org.RemoveOrgUserCommand) error - - Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } type sqlStore struct { @@ -399,72 +395,6 @@ func (ss *sqlStore) AddOrgUser(ctx context.Context, cmd *org.AddOrgUserCommand) }) } -func (ss *sqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - type result struct { - Count int64 - } - - r := result{} - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := "SELECT COUNT(*) as count from org" - if _, err := sess.SQL(rawSQL).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope) - if err != nil { - return u, err - } - u.Set(tag, r.Count) - } - - if scopeParams.OrgID != 0 { - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM (SELECT user_id FROM org_user WHERE org_id=? AND user_id IN (SELECT id AS user_id FROM %s WHERE is_service_account=%s)) as subq", - ss.db.GetDialect().Quote("user"), - ss.db.GetDialect().BooleanStr(false), - ) - if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope) - if err != nil { - return u, err - } - u.Set(tag, r.Count) - } - } - - if scopeParams.UserID != 0 { - if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - // should we exclude service accounts? - rawSQL := "SELECT COUNT(*) AS count FROM org_user WHERE user_id=?" - if _, err := sess.SQL(rawSQL, scopeParams.UserID).Get(&r); err != nil { - return err - } - return nil - }); err != nil { - return u, err - } else { - tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope) - if err != nil { - return u, err - } - u.Set(tag, r.Count) - } - } - - return u, nil -} - func setUsingOrgInTransaction(sess *db.Session, userID int64, orgID int64) error { user := user.User{ ID: userID, diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index 4a1eb4ff142..c5aa5caa787 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -28,7 +28,6 @@ import ( publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -301,8 +300,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) } // create dashboard - dashboardStoreService, err := dashboardStore.ProvideDashboardStore(db, db.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, db.Cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStoreService := dashboardStore.ProvideDashboardStore(db, db.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, db.Cfg)) dashboard, err := dashboardStoreService.SaveDashboard(context.Background(), saveDashboardCmd) require.NoError(t, err) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index b217e324a6c..6c66764b304 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -13,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -36,9 +35,7 @@ func TestIntegrationListPublicDashboard(t *testing.T) { t.Skip("skipping integration test") } sqlStore, cfg := db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore := ProvideStore(sqlStore) var orgId int64 = 1 @@ -81,10 +78,7 @@ func TestIntegrationFindDashboard(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) - dashboardStore = store + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -111,10 +105,7 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) - dashboardStore = store + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -184,10 +175,7 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) - dashboardStore = store + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -249,10 +237,7 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) - dashboardStore = store + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -314,12 +299,10 @@ func TestIntegrationFindByAccessToken(t *testing.T) { var dashboardStore *dashboardsDB.DashboardStore var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard - var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -386,10 +369,7 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) - dashboardStore = store + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, true) @@ -456,13 +436,10 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var anotherSavedDashboard *models.Dashboard - var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - quotaService := quotatest.New(false, nil) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, 0, true) @@ -552,13 +529,10 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { var dashboardStore *dashboardsDB.DashboardStore var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard - var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) - require.NoError(t, err) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -625,12 +599,10 @@ func TestIntegrationDelete(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var savedPublicDashboard *PublicDashboard - var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.Uid, savedDashboard.OrgId, true) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 79baf710054..884b82d8d59 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" @@ -356,8 +355,7 @@ const ( func TestGetQueryDataResponse(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) service := &PublicDashboardServiceImpl{ @@ -740,8 +738,7 @@ func TestGetAnnotations(t *testing.T) { func TestGetMetricRequest(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) publicDashboard := &PublicDashboard{ @@ -814,8 +811,7 @@ func TestGetUniqueDashboardDatasourceUids(t *testing.T) { func TestBuildMetricRequest(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -1026,8 +1022,7 @@ func TestBuildMetricRequest(t *testing.T) { func TestBuildAnonymousUser(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) //publicdashboardStore := database.ProvideStore(sqlStore) //service := &PublicDashboardServiceImpl{ diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index fcf700b36c4..caafbad220e 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -126,9 +125,7 @@ func TestGetPublicDashboard(t *testing.T) { func TestCreatePublicDashboard(t *testing.T) { t.Run("Create public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -150,7 +147,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err = service.Create(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -174,9 +171,7 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Validate pubdash has default time setting value", func(t *testing.T) { sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -196,7 +191,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err = service.Create(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -206,9 +201,7 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Validate pubdash whose dashboard has template variables returns error", func(t *testing.T) { sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) templateVars := make([]map[string]interface{}, 1) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, templateVars, nil) @@ -229,7 +222,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err = service.Create(context.Background(), SignedInUser, dto) + _, err := service.Create(context.Background(), SignedInUser, dto) require.Error(t, err) }) @@ -272,8 +265,7 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Returns error if public dashboard exists", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -324,9 +316,7 @@ func TestCreatePublicDashboard(t *testing.T) { func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -388,9 +378,7 @@ func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating set empty time settings", func(t *testing.T) { sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index af1d87c9263..32a17fcc787 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -23,7 +23,6 @@ import ( fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" dsSvc "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -390,9 +389,7 @@ func setup(t *testing.T) *testContext { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) ss := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) ssvc := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - quotaService := quotatest.New(false, nil) - ds, err := dsSvc.ProvideService(nil, ssvc, ss, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + ds := dsSvc.ProvideService(nil, ssvc, ss, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) fakeDatasourceService := &fakeDatasources.FakeDataSourceService{ DataSources: nil, SimulatePluginFailure: false, diff --git a/pkg/services/quota/context.go b/pkg/services/quota/context.go deleted file mode 100644 index 2342d53d476..00000000000 --- a/pkg/services/quota/context.go +++ /dev/null @@ -1,42 +0,0 @@ -package quota - -import ( - "context" - "sync" -) - -type Context struct { - context.Context - TargetToSrv *TargetToSrv -} - -func FromContext(ctx context.Context, targetToSrv *TargetToSrv) Context { - if targetToSrv == nil { - targetToSrv = NewTargetToSrv() - } - return Context{Context: ctx, TargetToSrv: targetToSrv} -} - -type TargetToSrv struct { - mutex sync.RWMutex - m map[Target]TargetSrv -} - -func NewTargetToSrv() *TargetToSrv { - return &TargetToSrv{m: make(map[Target]TargetSrv)} -} - -func (m *TargetToSrv) Get(target Target) (TargetSrv, bool) { - m.mutex.RLock() - defer m.mutex.RUnlock() - - srv, ok := m.m[target] - return srv, ok -} - -func (m *TargetToSrv) Set(target Target, srv TargetSrv) { - m.mutex.Lock() - defer m.mutex.Unlock() - - m.m[target] = srv -} diff --git a/pkg/services/quota/model.go b/pkg/services/quota/model.go index 091c60c4f36..d0e69700f68 100644 --- a/pkg/services/quota/model.go +++ b/pkg/services/quota/model.go @@ -1,216 +1,10 @@ package quota -import ( - "strings" - "sync" - "time" +import "errors" - "github.com/grafana/grafana/pkg/util/errutil" -) - -var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "quota.bad-request") -var ErrInvalidTargetSrv = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-target") -var ErrInvalidScope = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-scope") -var ErrInvalidTarget = errutil.NewBase(errutil.StatusInternal, "quota.invalid-target-table") -var ErrTargetSrvConflict = errutil.NewBase(errutil.StatusBadRequest, "quota.target-srv-conflict") -var ErrDisabled = errutil.NewBase(errutil.StatusForbidden, "quota.disabled", errutil.WithPublicMessage("Quotas not enabled")) -var ErrInvalidTagFormat = errutil.NewBase(errutil.StatusInternal, "quota.invalid-invalid-tag-format") +var ErrInvalidQuotaTarget = errors.New("invalid quota target") type ScopeParameters struct { OrgID int64 UserID int64 } - -type Scope string - -const ( - GlobalScope Scope = "global" - OrgScope Scope = "org" - UserScope Scope = "user" -) - -func (s Scope) Validate() error { - switch s { - case GlobalScope, OrgScope, UserScope: - return nil - default: - return ErrInvalidScope.Errorf("bad scope: %s", s) - } -} - -type TargetSrv string - -type Target string - -const delimiter = ":" - -// Tag is a string with the format :: -type Tag string - -func NewTag(srv TargetSrv, t Target, scope Scope) (Tag, error) { - if err := scope.Validate(); err != nil { - return "", err - } - - tag := Tag(strings.Join([]string{string(srv), string(t), string(scope)}, delimiter)) - return tag, nil -} - -func (t Tag) split() ([]string, error) { - parts := strings.SplitN(string(t), delimiter, -1) - if len(parts) != 3 { - return nil, ErrInvalidTagFormat.Errorf("tag format should be ^(?\\w):(?\\w):(?\\w)$") - } - - return parts, nil -} - -func (t Tag) GetSrv() (TargetSrv, error) { - parts, err := t.split() - if err != nil { - return "", err - } - return TargetSrv(parts[0]), nil -} - -func (t Tag) GetTarget() (Target, error) { - parts, err := t.split() - if err != nil { - return "", err - } - return Target(parts[1]), nil -} - -func (t Tag) GetScope() (Scope, error) { - parts, err := t.split() - if err != nil { - return "", err - } - return Scope(parts[2]), nil -} - -type Item struct { - Tag Tag - Value int64 -} - -type Map struct { - mutex sync.RWMutex - m map[Tag]int64 -} - -func (m *Map) Set(tag Tag, limit int64) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if len(m.m) == 0 { - m.m = make(map[Tag]int64, 0) - } - m.m[tag] = limit -} - -func (m *Map) Get(tag Tag) (int64, bool) { - m.mutex.RLock() - defer m.mutex.RUnlock() - - limit, ok := m.m[tag] - return limit, ok -} - -func (m *Map) Merge(l2 *Map) { - l2.mutex.RLock() - defer l2.mutex.RUnlock() - - for k, v := range l2.m { - // TODO check for conflicts? - m.Set(k, v) - } -} - -func (m *Map) Iter() <-chan Item { - m.mutex.RLock() - defer m.mutex.RUnlock() - - ch := make(chan Item) - go func() { - defer close(ch) - for t, v := range m.m { - ch <- Item{Tag: t, Value: v} - } - }() - - return ch -} - -func (m *Map) Scopes() (map[Scope]struct{}, error) { - res := make(map[Scope]struct{}) - for item := range m.Iter() { - scope, err := item.Tag.GetScope() - if err != nil { - return nil, err - } - res[scope] = struct{}{} - } - return res, nil -} - -func (m *Map) Services() (map[TargetSrv]struct{}, error) { - res := make(map[TargetSrv]struct{}) - for item := range m.Iter() { - srv, err := item.Tag.GetSrv() - if err != nil { - return nil, err - } - res[srv] = struct{}{} - } - return res, nil -} - -func (m *Map) Targets() (map[Target]struct{}, error) { - res := make(map[Target]struct{}) - for item := range m.Iter() { - target, err := item.Tag.GetTarget() - if err != nil { - return nil, err - } - res[target] = struct{}{} - } - return res, nil -} - -type Quota struct { - Id int64 - OrgId int64 - UserId int64 - Target string - Limit int64 - Created time.Time - Updated time.Time -} - -type QuotaDTO struct { - OrgId int64 `json:"org_id,omitempty"` - UserId int64 `json:"user_id,omitempty"` - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` - Service string `json:"-"` - Scope string `json:"-"` -} - -func (dto QuotaDTO) Tag() (Tag, error) { - return NewTag(TargetSrv(dto.Service), Target(dto.Target), Scope(dto.Scope)) -} - -type UpdateQuotaCmd struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - OrgID int64 `json:"-"` - UserID int64 `json:"-"` -} - -type NewUsageReporter struct { - TargetSrv TargetSrv - DefaultLimits *Map - Reporter UsageReporterFunc -} diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index 13045f41de2..90cc46c878b 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -7,24 +7,7 @@ import ( ) type Service interface { - // GetQuotasByScope returns the quota for the specific scope (global, organization, user) - // If the scope is organization, the ID is expected to be the organisation ID. - // If the scope is user, the id is expected to be the user ID. - GetQuotasByScope(ctx context.Context, scope Scope, ID int64) ([]QuotaDTO, error) - // Update overrides the quota for a specific scope (global, organization, user). - // If the cmd.OrgID is set, then the organization quota are updated. - // If the cmd.UseID is set, then the user quota are updated. - Update(ctx context.Context, cmd *UpdateQuotaCmd) error - // QuotaReached is called by the quota middleware for applying quota enforcement to API handlers - QuotaReached(c *models.ReqContext, targetSrv TargetSrv) (bool, error) - // CheckQuotaReached checks if the quota limitations have been reached for a specific service - CheckQuotaReached(ctx context.Context, targetSrv TargetSrv, scopeParams *ScopeParameters) (bool, error) - // DeleteQuotaForUser deletes custom quota limitations for the user - DeleteQuotaForUser(ctx context.Context, userID int64) error - // DeleteByOrg(ctx context.Context, orgID int64) error - - // RegisterQuotaReporter registers a service UsageReporterFunc, targets and their default limits - RegisterQuotaReporter(e *NewUsageReporter) error + QuotaReached(c *models.ReqContext, target string) (bool, error) + CheckQuotaReached(ctx context.Context, target string, scopeParams *ScopeParameters) (bool, error) + DeleteByUser(context.Context, int64) error } - -type UsageReporterFunc func(ctx context.Context, scopeParams *ScopeParameters) (*Map, error) diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index e435989fbfb..fb7f9fd6fc1 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -2,81 +2,38 @@ package quotaimpl import ( "context" - "fmt" - "sync" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "golang.org/x/sync/errgroup" ) -type serviceDisabled struct { +type Service struct { + store store + authTokenService models.ActiveTokenService + Cfg *setting.Cfg + SQLStore sqlstore.Store + Logger log.Logger } -func (s *serviceDisabled) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { - return false, nil -} - -func (s *serviceDisabled) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { - return nil, quota.ErrDisabled -} - -func (s *serviceDisabled) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { - return quota.ErrDisabled -} - -func (s *serviceDisabled) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { - return false, nil -} - -func (s *serviceDisabled) DeleteQuotaForUser(ctx context.Context, userID int64) error { - return quota.ErrDisabled -} - -func (s *serviceDisabled) RegisterQuotaReporter(e *quota.NewUsageReporter) error { - return nil -} - -type service struct { - store store - Cfg *setting.Cfg - Logger log.Logger - - mutex sync.RWMutex - reporters map[quota.TargetSrv]quota.UsageReporterFunc - - defaultLimits *quota.Map - - targetToSrv *quota.TargetToSrv -} - -func ProvideService(db db.DB, cfg *setting.Cfg) quota.Service { - logger := log.New("quota_service") - s := service{ - store: &sqlStore{db: db, logger: logger}, - Cfg: cfg, - Logger: logger, - reporters: make(map[quota.TargetSrv]quota.UsageReporterFunc), - defaultLimits: "a.Map{}, - targetToSrv: quota.NewTargetToSrv(), +func ProvideService(db db.DB, cfg *setting.Cfg, tokenService models.ActiveTokenService, ss *sqlstore.SQLStore) quota.Service { + return &Service{ + store: &sqlStore{db: db}, + Cfg: cfg, + authTokenService: tokenService, + SQLStore: ss, + Logger: log.New("quota_service"), } - - if s.IsDisabled() { - return &serviceDisabled{} - } - - return &s -} - -func (s *service) IsDisabled() bool { - return !s.Cfg.Quota.Enabled } // QuotaReached checks that quota is reached for a target. Runs CheckQuotaReached and take context and scope parameters from the request context -func (s *service) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { +func (s *Service) QuotaReached(c *models.ReqContext, target string) (bool, error) { + if !s.Cfg.Quota.Enabled { + return false, nil + } // No request context means this is a background service, like LDAP Background Sync if c == nil { return false, nil @@ -89,129 +46,91 @@ func (s *service) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) UserID: c.UserID, } } - return s.CheckQuotaReached(c.Req.Context(), targetSrv, params) -} - -func (s *service) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { - if err := scope.Validate(); err != nil { - return nil, err - } - - q := make([]quota.QuotaDTO, 0) - - scopeParams := quota.ScopeParameters{} - if scope == quota.OrgScope { - scopeParams.OrgID = id - } else if scope == quota.UserScope { - scopeParams.UserID = id - } - - c, err := s.getContext(ctx) - if err != nil { - return nil, err - } - customLimits, err := s.store.Get(c, &scopeParams) - if err != nil { - return nil, err - } - - u, err := s.getUsage(ctx, &scopeParams) - if err != nil { - return nil, err - } - - for item := range s.defaultLimits.Iter() { - limit := item.Value - - scp, err := item.Tag.GetScope() - if err != nil { - return nil, err - } - - if scp != scope { - continue - } - - if targetCustomLimit, ok := customLimits.Get(item.Tag); ok { - limit = targetCustomLimit - } - - target, err := item.Tag.GetTarget() - if err != nil { - return nil, err - } - - srv, err := item.Tag.GetSrv() - if err != nil { - return nil, err - } - - used, _ := u.Get(item.Tag) - q = append(q, quota.QuotaDTO{ - Target: string(target), - Limit: limit, - OrgId: scopeParams.OrgID, - UserId: scopeParams.UserID, - Used: used, - Service: string(srv), - Scope: string(scope), - }) - } - - return q, nil -} - -func (s *service) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { - targetFound := false - knownTargets, err := s.defaultLimits.Targets() - if err != nil { - return err - } - - for t := range knownTargets { - if t == quota.Target(cmd.Target) { - targetFound = true - } - } - if !targetFound { - return quota.ErrInvalidTarget.Errorf("unknown quota target: %s", cmd.Target) - } - - c, err := s.getContext(ctx) - if err != nil { - return err - } - return s.store.Update(c, cmd) + return s.CheckQuotaReached(c.Req.Context(), target, params) } // CheckQuotaReached check that quota is reached for a target. If ScopeParameters are not defined, only global scope is checked -func (s *service) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { - targetSrvLimits, err := s.getOverridenLimits(ctx, targetSrv, scopeParams) +func (s *Service) CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) { + if !s.Cfg.Quota.Enabled { + return false, nil + } + // get the list of scopes that this target is valid for. Org, User, Global + scopes, err := s.getQuotaScopes(target) if err != nil { return false, err } + for _, scope := range scopes { + s.Logger.Debug("Checking quota", "target", target, "scope", scope) - usageReporterFunc, ok := s.getReporter(targetSrv) - if !ok { - return false, quota.ErrInvalidTargetSrv - } - targetUsage, err := usageReporterFunc(ctx, scopeParams) - if err != nil { - return false, err - } - - for t, limit := range targetSrvLimits { - switch { - case limit < 0: - continue - case limit == 0: - return true, nil - default: - u, ok := targetUsage.Get(t) - if !ok { - return false, fmt.Errorf("no usage for target:%s", t) + switch scope.Name { + case "global": + if scope.DefaultLimit < 0 { + continue } - if u >= limit { + if scope.DefaultLimit == 0 { + return true, nil + } + if target == "session" { + usedSessions, err := s.authTokenService.ActiveTokenCount(ctx) + if err != nil { + return false, err + } + + if usedSessions > scope.DefaultLimit { + s.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit) + return true, nil + } + continue + } + query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target, UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled()} + // TODO : move GetGlobalQuotaByTarget to a global quota service + if err := s.SQLStore.GetGlobalQuotaByTarget(ctx, &query); err != nil { + return true, err + } + if query.Result.Used >= scope.DefaultLimit { + return true, nil + } + case "org": + if scopeParams == nil { + continue + } + query := models.GetOrgQuotaByTargetQuery{ + OrgId: scopeParams.OrgID, + Target: scope.Target, + Default: scope.DefaultLimit, + UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled(), + } + // TODO: move GetOrgQuotaByTarget from sqlstore to quota store + if err := s.SQLStore.GetOrgQuotaByTarget(ctx, &query); err != nil { + return true, err + } + if query.Result.Limit < 0 { + continue + } + if query.Result.Limit == 0 { + return true, nil + } + + if query.Result.Used >= query.Result.Limit { + return true, nil + } + case "user": + if scopeParams == nil || scopeParams.UserID == 0 { + continue + } + query := models.GetUserQuotaByTargetQuery{UserId: scopeParams.UserID, Target: scope.Target, Default: scope.DefaultLimit, UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled()} + // TODO: move GetUserQuotaByTarget from sqlstore to quota store + if err := s.SQLStore.GetUserQuotaByTarget(ctx, &query); err != nil { + return true, err + } + if query.Result.Limit < 0 { + continue + } + if query.Result.Limit == 0 { + return true, nil + } + + if query.Result.Used >= query.Result.Limit { return true, nil } } @@ -219,127 +138,68 @@ func (s *service) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetS return false, nil } -func (s *service) DeleteQuotaForUser(ctx context.Context, userID int64) error { - c, err := s.getContext(ctx) - if err != nil { - return err +func (s *Service) getQuotaScopes(target string) ([]models.QuotaScope, error) { + scopes := make([]models.QuotaScope, 0) + switch target { + case "user": + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.User}, + models.QuotaScope{Name: "org", Target: "org_user", DefaultLimit: s.Cfg.Quota.Org.User}, + ) + return scopes, nil + case "org": + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.Org}, + models.QuotaScope{Name: "user", Target: "org_user", DefaultLimit: s.Cfg.Quota.User.Org}, + ) + return scopes, nil + case "dashboard": + scopes = append(scopes, + models.QuotaScope{ + Name: "global", + Target: target, + DefaultLimit: s.Cfg.Quota.Global.Dashboard, + }, + models.QuotaScope{ + Name: "org", + Target: target, + DefaultLimit: s.Cfg.Quota.Org.Dashboard, + }, + ) + return scopes, nil + case "data_source": + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.DataSource}, + models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.DataSource}, + ) + return scopes, nil + case "api_key": + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.ApiKey}, + models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.ApiKey}, + ) + return scopes, nil + case "session": + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.Session}, + ) + return scopes, nil + case "alert_rule": // target need to match the respective database name + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.AlertRule}, + models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.AlertRule}, + ) + return scopes, nil + case "file": + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.File}, + ) + return scopes, nil + default: + return scopes, quota.ErrInvalidQuotaTarget } - return s.store.DeleteByUser(c, userID) } -func (s *service) RegisterQuotaReporter(e *quota.NewUsageReporter) error { - s.mutex.Lock() - defer s.mutex.Unlock() - - _, ok := s.reporters[e.TargetSrv] - if ok { - return quota.ErrTargetSrvConflict.Errorf("target service: %s already exists", e.TargetSrv) - } - - s.reporters[e.TargetSrv] = e.Reporter - - for item := range e.DefaultLimits.Iter() { - target, err := item.Tag.GetTarget() - if err != nil { - return err - } - srv, err := item.Tag.GetSrv() - if err != nil { - return err - } - s.targetToSrv.Set(target, srv) - s.defaultLimits.Set(item.Tag, item.Value) - } - - return nil -} - -func (s *service) getReporter(target quota.TargetSrv) (quota.UsageReporterFunc, bool) { - s.mutex.RLock() - defer s.mutex.RUnlock() - - r, ok := s.reporters[target] - return r, ok -} - -type reporter struct { - target quota.TargetSrv - reporterFunc quota.UsageReporterFunc -} - -func (s *service) getReporters() <-chan reporter { - ch := make(chan reporter) - go func() { - s.mutex.RLock() - defer func() { - s.mutex.RUnlock() - close(ch) - }() - for t, r := range s.reporters { - ch <- reporter{target: t, reporterFunc: r} - } - }() - - return ch -} - -func (s *service) getOverridenLimits(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (map[quota.Tag]int64, error) { - targetSrvLimits := make(map[quota.Tag]int64) - - c, err := s.getContext(ctx) - if err != nil { - return nil, err - } - customLimits, err := s.store.Get(c, scopeParams) - if err != nil { - return targetSrvLimits, err - } - - for item := range s.defaultLimits.Iter() { - srv, err := item.Tag.GetSrv() - if err != nil { - return nil, err - } - - if srv != targetSrv { - continue - } - - defaultLimit := item.Value - - if customLimit, ok := customLimits.Get(item.Tag); ok { - targetSrvLimits[item.Tag] = customLimit - } else { - targetSrvLimits[item.Tag] = defaultLimit - } - } - - return targetSrvLimits, nil -} - -func (s *service) getUsage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - usage := "a.Map{} - g, ctx := errgroup.WithContext(ctx) - - for r := range s.getReporters() { - r := r - g.Go(func() error { - u, err := r.reporterFunc(ctx, scopeParams) - if err != nil { - return err - } - usage.Merge(u) - return nil - }) - } - - if err := g.Wait(); err != nil { - return nil, err - } - - return usage, nil -} - -func (s *service) getContext(ctx context.Context) (quota.Context, error) { - return quota.FromContext(ctx, s.targetToSrv), nil +func (s *Service) DeleteByUser(ctx context.Context, userID int64) error { + return s.store.DeleteByUser(ctx, userID) } diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 17164adc785..c2cdfd5edda 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -3,481 +3,26 @@ package quotaimpl import ( "context" "testing" - "time" - "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" - "github.com/grafana/grafana/pkg/services/auth" - "github.com/grafana/grafana/pkg/services/dashboards" - dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" - "github.com/grafana/grafana/pkg/services/datasources" - dsservice "github.com/grafana/grafana/pkg/services/datasources/service" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/ngalert" - "github.com/grafana/grafana/pkg/services/ngalert/metrics" - ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - ngalerttests "github.com/grafana/grafana/pkg/services/ngalert/tests" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/secrets/fakes" - secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" - secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" - "github.com/grafana/grafana/pkg/services/sqlstore" - storesrv "github.com/grafana/grafana/pkg/services/store" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" - "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" - "github.com/xorcare/pointer" ) func TestQuotaService(t *testing.T) { - quotaStore := "atest.FakeQuotaStore{} - quotaService := service{ + quotaStore := &FakeQuotaStore{} + quotaService := Service{ store: quotaStore, } t.Run("delete quota", func(t *testing.T) { - err := quotaService.DeleteQuotaForUser(context.Background(), 1) + err := quotaService.DeleteByUser(context.Background(), 1) require.NoError(t, err) }) } -func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - sqlStore := sqlstore.InitTestDB(t) - sqlStore.Cfg.Quota = setting.QuotaSettings{ - Enabled: true, - - Org: setting.OrgQuota{ - User: 2, - Dashboard: 3, - DataSource: 4, - ApiKey: 5, - AlertRule: 6, - }, - User: setting.UserQuota{ - Org: 7, - }, - Global: setting.GlobalQuota{ - Org: 8, - User: 9, - Dashboard: 10, - DataSource: 11, - ApiKey: 12, - Session: 13, - AlertRule: 14, - File: 15, - }, - } - - b := bus.ProvideBus(tracing.InitializeTracerForTest()) - quotaService := ProvideService(sqlStore, sqlStore.Cfg) - orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) - require.NoError(t, err) - userService, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService) - require.NoError(t, err) - setupEnv(t, sqlStore, b, quotaService) - - u, err := userService.Create(context.Background(), &user.CreateUserCommand{ - Name: "TestUser", - SkipOrgSetup: true, - }) - require.NoError(t, err) - - o, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{ - Name: "TestOrg", - UserID: u.ID, - }) - require.NoError(t, err) - - // fetch global default limit/usage - defaultGlobalLimits := make(map[quota.Tag]int64) - existingGlobalUsage := make(map[quota.Tag]int64) - scope := quota.GlobalScope - result, err := quotaService.GetQuotasByScope(context.Background(), scope, 0) - require.NoError(t, err) - for _, r := range result { - tag, err := r.Tag() - require.NoError(t, err) - defaultGlobalLimits[tag] = r.Limit - existingGlobalUsage[tag] = r.Used - } - tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.Org, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.User, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.Dashboard, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.DataSource, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.ApiKey, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.Session, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.AlertRule, defaultGlobalLimits[tag]) - tag, err = quota.NewTag(storesrv.QuotaTargetSrv, storesrv.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Global.File, defaultGlobalLimits[tag]) - - // fetch default limit/usage for org - defaultOrgLimits := make(map[quota.Tag]int64) - existingOrgUsage := make(map[quota.Tag]int64) - scope = quota.OrgScope - result, err = quotaService.GetQuotasByScope(context.Background(), scope, o.ID) - require.NoError(t, err) - for _, r := range result { - tag, err := r.Tag() - require.NoError(t, err) - defaultOrgLimits[tag] = r.Limit - existingOrgUsage[tag] = r.Used - } - tag, err = quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Org.User, defaultOrgLimits[tag]) - tag, err = quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Org.Dashboard, defaultOrgLimits[tag]) - tag, err = quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Org.DataSource, defaultOrgLimits[tag]) - tag, err = quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Org.ApiKey, defaultOrgLimits[tag]) - tag, err = quota.NewTag(ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.Org.AlertRule, defaultOrgLimits[tag]) - - // fetch default limit/usage for user - defaultUserLimits := make(map[quota.Tag]int64) - existingUserUsage := make(map[quota.Tag]int64) - scope = quota.UserScope - result, err = quotaService.GetQuotasByScope(context.Background(), scope, u.ID) - require.NoError(t, err) - for _, r := range result { - tag, err := r.Tag() - require.NoError(t, err) - defaultUserLimits[tag] = r.Limit - existingUserUsage[tag] = r.Used - } - tag, err = quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), scope) - require.NoError(t, err) - require.Equal(t, sqlStore.Cfg.Quota.User.Org, defaultUserLimits[tag]) - - t.Run("Given saved org quota for users", func(t *testing.T) { - // update quota for the created org and limit users to 1 - var customOrgUserLimit int64 = 1 - orgCmd := quota.UpdateQuotaCmd{ - OrgID: o.ID, - Target: org.OrgUserQuotaTarget, - Limit: customOrgUserLimit, - } - err := quotaService.Update(context.Background(), &orgCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved limit/usage for org users", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) - require.NoError(t, err) - - require.Equal(t, customOrgUserLimit, q.Limit) - require.Equal(t, int64(1), q.Used) - }) - - t.Run("Should be able to get default org users limit/usage for unknown org", func(t *testing.T) { - unknownOrgID := -1 - q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: int64(unknownOrgID)}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, defaultOrgLimits[tag], q.Limit) - require.Equal(t, int64(0), q.Used) - }) - - t.Run("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { - // disable Grafana Alerting - cfg := *sqlStore.Cfg - cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{Enabled: pointer.Bool(false)} - - quotaSrv := ProvideService(sqlStore, &cfg) - q, err := getQuotaBySrvTargetScope(t, quotaSrv, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) - - require.NoError(t, err) - require.Equal(t, int64(0), q.Limit) - }) - - t.Run("Should be able to quota list for org", func(t *testing.T) { - result, err := quotaService.GetQuotasByScope(context.Background(), quota.OrgScope, o.ID) - require.NoError(t, err) - require.Len(t, result, 5) - - require.NoError(t, err) - for _, res := range result { - tag, err := res.Tag() - require.NoError(t, err) - limit := defaultOrgLimits[tag] - used := existingOrgUsage[tag] - if res.Target == org.OrgUserQuotaTarget { - limit = customOrgUserLimit - used = 1 // one user in the created org - } - require.Equal(t, limit, res.Limit) - require.Equal(t, used, res.Used) - } - }) - }) - - t.Run("Given saved org quota for dashboards", func(t *testing.T) { - // update quota for the created org and limit dashboards to 1 - var customOrgDashboardLimit int64 = 1 - orgCmd := quota.UpdateQuotaCmd{ - OrgID: o.ID, - Target: string(dashboards.QuotaTarget), - Limit: customOrgDashboardLimit, - } - err := quotaService.Update(context.Background(), &orgCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, customOrgDashboardLimit, q.Limit) - require.Equal(t, existingOrgUsage[tag], q.Used) - }) - }) - - t.Run("Given saved user quota for org", func(t *testing.T) { - // update quota for the created user and limit orgs to 1 - var customUserOrgsLimit int64 = 1 - userQuotaCmd := quota.UpdateQuotaCmd{ - UserID: u.ID, - Target: org.OrgUserQuotaTarget, - Limit: customUserOrgsLimit, - } - err := quotaService.Update(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved limit/usage for user orgs", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) - require.NoError(t, err) - - require.Equal(t, customUserOrgsLimit, q.Limit) - require.Equal(t, int64(1), q.Used) - }) - - t.Run("Should be able to get default user orgs limit/usage for unknown user", func(t *testing.T) { - var unknownUserID int64 = -1 - q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: unknownUserID}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, defaultUserLimits[tag], q.Limit) - require.Equal(t, int64(0), q.Used) - }) - - t.Run("Should be able to quota list for user", func(t *testing.T) { - result, err = quotaService.GetQuotasByScope(context.Background(), quota.UserScope, u.ID) - require.NoError(t, err) - require.Len(t, result, 1) - for _, res := range result { - tag, err := res.Tag() - require.NoError(t, err) - limit := defaultUserLimits[tag] - used := existingUserUsage[tag] - if res.Target == org.OrgUserQuotaTarget { - limit = customUserOrgsLimit // customized quota limit. - used = 1 // one user in the created org - } - require.Equal(t, limit, res.Limit) - require.Equal(t, used, res.Used) - } - }) - }) - - t.Run("Should be able to global user quota", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope, "a.ScopeParameters{}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, defaultGlobalLimits[tag], q.Limit) - require.Equal(t, int64(1), q.Used) - }) - - t.Run("Should be able to global org quota", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope, "a.ScopeParameters{}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, defaultGlobalLimits[tag], q.Limit) - require.Equal(t, int64(1), q.Used) - }) - - t.Run("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.GlobalScope, "a.ScopeParameters{}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, defaultGlobalLimits[tag], q.Limit) - require.Equal(t, int64(0), q.Used) - }) - - t.Run("Should be able to global dashboard quota", func(t *testing.T) { - q, err := getQuotaBySrvTargetScope(t, quotaService, dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope, "a.ScopeParameters{}) - require.NoError(t, err) - - tag, err := q.Tag() - require.NoError(t, err) - require.Equal(t, defaultGlobalLimits[tag], q.Limit) - require.Equal(t, int64(0), q.Used) - }) - - // related: https://github.com/grafana/grafana/issues/14342 - t.Run("Should org quota updating is successful even if it called multiple time", func(t *testing.T) { - // update quota for the created org and limit users to 1 - var customOrgUserLimit int64 = 1 - orgCmd := quota.UpdateQuotaCmd{ - OrgID: o.ID, - Target: org.OrgUserQuotaTarget, - Limit: customOrgUserLimit, - } - err := quotaService.Update(context.Background(), &orgCmd) - require.NoError(t, err) - - query, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) - require.NoError(t, err) - require.Equal(t, customOrgUserLimit, query.Limit) - - // XXX: resolution of `Updated` column is 1sec, so this makes delay - time.Sleep(1 * time.Second) - - customOrgUserLimit = 2 - orgCmd = quota.UpdateQuotaCmd{ - OrgID: o.ID, - Target: org.OrgUserQuotaTarget, - Limit: customOrgUserLimit, - } - err = quotaService.Update(context.Background(), &orgCmd) - require.NoError(t, err) - - query, err = getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) - require.NoError(t, err) - require.Equal(t, customOrgUserLimit, query.Limit) - }) - - // related: https://github.com/grafana/grafana/issues/14342 - t.Run("Should user quota updating is successful even if it called multiple time", func(t *testing.T) { - // update quota for the created org and limit users to 1 - var customUserOrgLimit int64 = 1 - userQuotaCmd := quota.UpdateQuotaCmd{ - UserID: u.ID, - Target: org.OrgUserQuotaTarget, - Limit: customUserOrgLimit, - } - err := quotaService.Update(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - query, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) - require.NoError(t, err) - require.Equal(t, customUserOrgLimit, query.Limit) - - // XXX: resolution of `Updated` column is 1sec, so this makes delay - time.Sleep(1 * time.Second) - - customUserOrgLimit = 10 - userQuotaCmd = quota.UpdateQuotaCmd{ - UserID: u.ID, - Target: org.OrgUserQuotaTarget, - Limit: customUserOrgLimit, - } - err = quotaService.Update(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - query, err = getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) - require.NoError(t, err) - require.Equal(t, customUserOrgLimit, query.Limit) - }) - - // TODO data_source, file +type FakeQuotaStore struct { + ExpectedError error } -func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quota.TargetSrv, target quota.Target, scope quota.Scope, scopeParams *quota.ScopeParameters) (quota.QuotaDTO, error) { - t.Helper() - - var id int64 = 0 - switch { - case scope == quota.OrgScope: - id = scopeParams.OrgID - case scope == quota.UserScope: - id = scopeParams.UserID - } - - result, err := quotaService.GetQuotasByScope(context.Background(), scope, id) - require.NoError(t, err) - for _, r := range result { - if r.Target != string(target) { - continue - } - - if r.Service != string(srv) { - continue - } - - if r.Scope != string(scope) { - continue - } - - require.Equal(t, r.OrgId, scopeParams.OrgID) - require.Equal(t, r.UserId, scopeParams.UserID) - return r, nil - } - return quota.QuotaDTO{}, err -} - -func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService quota.Service) { - _, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) - require.NoError(t, err) - _, err = auth.ProvideActiveAuthTokenService(sqlStore.Cfg, sqlStore, quotaService) - require.NoError(t, err) - _, err = dashboardStore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) - require.NoError(t, err) - secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - _, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New().WithDisabled(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) - m := metrics.NewNGAlert(prometheus.NewRegistry()) - _, err = ngalert.ProvideService( - sqlStore.Cfg, &ngalerttests.FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotaService, - secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, annotationstest.NewFakeAnnotationsRepo(), - ) - require.NoError(t, err) - _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService) - require.NoError(t, err) +func (f *FakeQuotaStore) DeleteByUser(ctx context.Context, userID int64) error { + return f.ExpectedError } diff --git a/pkg/services/quota/quotaimpl/store.go b/pkg/services/quota/quotaimpl/store.go index d6111580f28..6b3a32bdb91 100644 --- a/pkg/services/quota/quotaimpl/store.go +++ b/pkg/services/quota/quotaimpl/store.go @@ -1,130 +1,23 @@ package quotaimpl import ( - "time" + "context" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" ) type store interface { - Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) - Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error - DeleteByUser(quota.Context, int64) error + DeleteByUser(context.Context, int64) error } type sqlStore struct { - db db.DB - logger log.Logger + db db.DB } -func (ss *sqlStore) DeleteByUser(ctx quota.Context, userID int64) error { +func (ss *sqlStore) DeleteByUser(ctx context.Context, userID int64) error { return ss.db.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM quota WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err }) } - -func (ss *sqlStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - limits := quota.Map{} - if scopeParams.OrgID != 0 { - orgLimits, err := ss.getOrgScopeQuota(ctx, scopeParams.OrgID) - if err != nil { - return nil, err - } - limits.Merge(orgLimits) - } - - if scopeParams.UserID != 0 { - userLimits, err := ss.getUserScopeQuota(ctx, scopeParams.UserID) - if err != nil { - return nil, err - } - limits.Merge(userLimits) - } - - return &limits, nil -} - -func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { - return ss.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { - // Check if quota is already defined in the DB - quota := quota.Quota{ - Target: cmd.Target, - UserId: cmd.UserID, - OrgId: cmd.OrgID, - } - has, err := sess.Get("a) - if err != nil { - return err - } - quota.Updated = time.Now() - quota.Limit = cmd.Limit - if !has { - quota.Created = time.Now() - // No quota in the DB for this target, so create a new one. - if _, err := sess.Insert("a); err != nil { - return err - } - } else { - // update existing quota entry in the DB. - _, err := sess.ID(quota.Id).Update("a) - if err != nil { - return err - } - } - - return nil - }) -} - -func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.Map, error) { - r := quota.Map{} - err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - quotas := make([]*quota.Quota, 0) - if err := sess.Table("quota").Where("user_id=? AND org_id=0", userID).Find("as); err != nil { - return err - } - - for _, q := range quotas { - srv, ok := ctx.TargetToSrv.Get(quota.Target(q.Target)) - if !ok { - ss.logger.Info("failed to get service for target", "target", q.Target) - } - tag, err := quota.NewTag(srv, quota.Target(q.Target), quota.UserScope) - if err != nil { - return err - } - r.Set(tag, q.Limit) - } - return nil - }) - return &r, err -} - -func (ss *sqlStore) getOrgScopeQuota(ctx quota.Context, OrgID int64) (*quota.Map, error) { - r := quota.Map{} - err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - quotas := make([]*quota.Quota, 0) - if err := sess.Table("quota").Where("user_id=0 AND org_id=?", OrgID).Find("as); err != nil { - return err - } - - for _, q := range quotas { - srv, ok := ctx.TargetToSrv.Get(quota.Target(q.Target)) - if !ok { - ss.logger.Info("failed to get service for target", "target", q.Target) - } - tag, err := quota.NewTag(srv, quota.Target(q.Target), quota.OrgScope) - if err != nil { - return err - } - r.Set(tag, q.Limit) - } - return nil - }) - return &r, err -} diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index d332ab97851..f9f7a184456 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/quota" ) func TestIntegrationQuotaDataAccess(t *testing.T) { @@ -21,8 +20,7 @@ func TestIntegrationQuotaDataAccess(t *testing.T) { } t.Run("quota deleted", func(t *testing.T) { - ctx := quota.FromContext(context.Background(), "a.TargetToSrv{}) - err := quotaStore.DeleteByUser(ctx, 1) + err := quotaStore.DeleteByUser(context.Background(), 1) require.NoError(t, err) }) } diff --git a/pkg/services/quota/quotatest/fake.go b/pkg/services/quota/quotatest/fake.go index d62267d9276..00eae845789 100644 --- a/pkg/services/quota/quotatest/fake.go +++ b/pkg/services/quota/quotatest/fake.go @@ -12,46 +12,18 @@ type FakeQuotaService struct { err error } -func New(reached bool, err error) *FakeQuotaService { - return &FakeQuotaService{reached, err} +func NewQuotaServiceFake() *FakeQuotaService { + return &FakeQuotaService{} } -func (f *FakeQuotaService) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { - return []quota.QuotaDTO{}, nil -} - -func (f *FakeQuotaService) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { - return nil -} - -func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { +func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target string) (bool, error) { return f.reached, f.err } -func (f *FakeQuotaService) CheckQuotaReached(c context.Context, target quota.TargetSrv, params *quota.ScopeParameters) (bool, error) { +func (f *FakeQuotaService) CheckQuotaReached(c context.Context, target string, params *quota.ScopeParameters) (bool, error) { return f.reached, f.err } -func (f *FakeQuotaService) DeleteQuotaForUser(c context.Context, userID int64) error { +func (f *FakeQuotaService) DeleteByUser(c context.Context, userID int64) error { return f.err } - -func (f *FakeQuotaService) RegisterQuotaReporter(e *quota.NewUsageReporter) error { - return f.err -} - -type FakeQuotaStore struct { - ExpectedError error -} - -func (f *FakeQuotaStore) DeleteByUser(ctx quota.Context, userID int64) error { - return f.ExpectedError -} - -func (f *FakeQuotaStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { - return nil, f.ExpectedError -} - -func (f *FakeQuotaStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { - return f.ExpectedError -} diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index ded9d12412f..a8e8ef8bcaa 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -14,7 +13,6 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" dsservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -29,9 +27,7 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS features = featuremgmt.WithFeatures(featuremgmt.FlagDisableSecretsCompatibility, true) } secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - quotaService := quotatest.New(false, nil) - dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService(), quotaService) - require.NoError(t, err) + dsService := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService()) migService := ProvideDataSourceMigrationService(dsService, kvStore, features) return migService } diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 8abeeb43789..0e87ed07c82 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -27,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" @@ -45,12 +44,9 @@ var ( func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) kvStore := kvstore.ProvideService(store) - orgService, err := orgimpl.ProvideService(store, setting.NewCfg(), quotaService) - require.NoError(t, err) + orgService := orgimpl.ProvideService(store, setting.NewCfg()) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, orgService) svcmock := tests.ServiceAccountMock{} @@ -61,7 +57,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { }() orgCmd := &models.CreateOrgCommand{Name: "Some Test Org"} - err = store.CreateOrg(context.Background(), orgCmd) + err := store.CreateOrg(context.Background(), orgCmd) require.Nil(t, err) type testCreateSATestCase struct { @@ -216,9 +212,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { store := db.InitTestDB(t) kvStore := kvstore.ProvideService(store) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -290,9 +284,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, sqlStore db.DB, saStore serviceaccounts.Store) (*web.Mux, *ServiceAccountsAPI) { cfg := setting.NewCfg() teamSvc := teamimpl.ProvideService(sqlStore, cfg) - - userSvc, err := userimpl.ProvideService(sqlStore, nil, cfg, teamimpl.ProvideService(sqlStore, cfg), nil, quotatest.New(false, nil)) - require.NoError(t, err) + userSvc := userimpl.ProvideService(sqlStore, nil, cfg, teamimpl.ProvideService(sqlStore, cfg), nil) saPermissionService, err := ossaccesscontrol.ProvideServiceAccountPermissions( cfg, routing.NewRouteRegister(), sqlStore, acmock, &licensing.OSSLicensingService{}, saStore, acmock, teamSvc, userSvc) require.NoError(t, err) @@ -324,9 +316,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -418,9 +408,7 @@ func newString(s string) *string { func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 90b234d24d2..9e9e91f4d98 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -23,7 +23,6 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" @@ -55,9 +54,7 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, func TestServiceAccountsAPI_CreateToken(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -174,9 +171,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { func TestServiceAccountsAPI_DeleteToken(t *testing.T) { store := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) kvStore := kvstore.ProvideService(store) svcMock := &tests.ServiceAccountMock{} saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index be9011ed9bc..a6aba5f1367 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -113,12 +112,9 @@ func TestStore_DeleteServiceAccount(t *testing.T) { func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreImpl) { t.Helper() db := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(db, db.Cfg, quotaService) - require.NoError(t, err) + apiKeyService := apikeyimpl.ProvideService(db, db.Cfg) kvStore := kvstore.ProvideService(db) - orgService, err := orgimpl.ProvideService(db, setting.NewCfg(), quotaService) - require.NoError(t, err) + orgService := orgimpl.ProvideService(db, setting.NewCfg()) return db, ProvideServiceAccountsStore(db, apiKeyService, kvStore, orgService) } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index d8b5dea247b..2671cc065e3 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" @@ -71,10 +70,8 @@ func SetupApiKey(t *testing.T, sqlStore *sqlstore.SQLStore, testKey TestApiKey) addKeyCmd.Key = "secret" } - quotaService := quotatest.New(false, nil) - apiKeyService, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) - require.NoError(t, err) - err = apiKeyService.AddAPIKey(context.Background(), addKeyCmd) + apiKeyService := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg) + err := apiKeyService.AddAPIKey(context.Background(), addKeyCmd) require.NoError(t, err) if testKey.IsExpired { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 1b4757b2e93..bf23c79e8a2 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -98,6 +98,34 @@ func (m *SQLStoreMock) WithNewDbSession(ctx context.Context, callback sqlstore.D return m.ExpectedError } +func (m *SQLStoreMock) GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error { + return m.ExpectedError +} + +func (m *SQLStoreMock) GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error { + return m.ExpectedError +} + func (m *SQLStoreMock) WithTransactionalDbSession(ctx context.Context, callback sqlstore.DBTransactionFunc) error { return m.ExpectedError } diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go new file mode 100644 index 00000000000..a28dba881d7 --- /dev/null +++ b/pkg/services/sqlstore/quota.go @@ -0,0 +1,315 @@ +package sqlstore + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +const ( + alertRuleTarget = "alert_rule" + dashboardTarget = "dashboard" + filesTarget = "file" +) + +type targetCount struct { + Count int64 +} + +func (ss *SQLStore) GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { + quota := models.Quota{ + Target: query.Target, + OrgId: query.OrgId, + } + has, err := sess.Get("a) + if err != nil { + return err + } else if !has { + quota.Limit = query.Default + } + + var used int64 + if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s WHERE org_id=?", + dialect.Quote(query.Target)) + + if query.Target == dashboardTarget { + rawSQL += fmt.Sprintf(" AND is_folder=%s", dialect.BooleanStr(false)) + } + // need to account for removing service accounts from the user table + if query.Target == "org_user" { + rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from (select user_id from %s where org_id=? AND user_id IN (SELECT id as user_id FROM %s WHERE is_service_account=%s)) as subq", + dialect.Quote(query.Target), + dialect.Quote("user"), + dialect.BooleanStr(false), + ) + } + resp := make([]*targetCount, 0) + if err := sess.SQL(rawSQL, query.OrgId).Find(&resp); err != nil { + return err + } + used = resp[0].Count + } + + query.Result = &models.OrgQuotaDTO{ + Target: query.Target, + Limit: quota.Limit, + OrgId: query.OrgId, + Used: used, + } + + return nil + }) +} + +func (ss *SQLStore) GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { + quotas := make([]*models.Quota, 0) + if err := sess.Table("quota").Where("org_id=? AND user_id=0", query.OrgId).Find("as); err != nil { + return err + } + + defaultQuotas := setting.Quota.Org.ToMap() + + seenTargets := make(map[string]bool) + for _, q := range quotas { + seenTargets[q.Target] = true + } + + for t, v := range defaultQuotas { + if _, ok := seenTargets[t]; !ok { + quotas = append(quotas, &models.Quota{ + OrgId: query.OrgId, + Target: t, + Limit: v, + }) + } + } + + result := make([]*models.OrgQuotaDTO, len(quotas)) + for i, q := range quotas { + var used int64 + var rawSQL string + if q.Target != alertRuleTarget || query.UnifiedAlertingEnabled { + // get quota used. + rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(q.Target)) + + // need to account for removing service accounts from the user table + if q.Target == "org_user" { + rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from (select user_id from %s where org_id=? AND user_id IN (SELECT id as user_id FROM %s WHERE is_service_account=%s)) as subq", + dialect.Quote(q.Target), + dialect.Quote("user"), + dialect.BooleanStr(false), + ) + } + resp := make([]*targetCount, 0) + if err := sess.SQL(rawSQL, q.OrgId).Find(&resp); err != nil { + return err + } + used = resp[0].Count + } + result[i] = &models.OrgQuotaDTO{ + Target: q.Target, + Limit: q.Limit, + OrgId: q.OrgId, + Used: used, + } + } + query.Result = result + return nil + }) +} + +func (ss *SQLStore) UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error { + return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { + // Check if quota is already defined in the DB + quota := models.Quota{ + Target: cmd.Target, + OrgId: cmd.OrgId, + } + has, err := sess.Get("a) + if err != nil { + return err + } + quota.Updated = time.Now() + quota.Limit = cmd.Limit + if !has { + quota.Created = time.Now() + // No quota in the DB for this target, so create a new one. + if _, err := sess.Insert("a); err != nil { + return err + } + } else { + // update existing quota entry in the DB. + _, err := sess.ID(quota.Id).Update("a) + if err != nil { + return err + } + } + + return nil + }) +} + +func (ss *SQLStore) GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { + quota := models.Quota{ + Target: query.Target, + UserId: query.UserId, + } + has, err := sess.Get("a) + if err != nil { + return err + } else if !has { + quota.Limit = query.Default + } + + var used int64 + if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(query.Target)) + resp := make([]*targetCount, 0) + if err := sess.SQL(rawSQL, query.UserId).Find(&resp); err != nil { + return err + } + used = resp[0].Count + } + + query.Result = &models.UserQuotaDTO{ + Target: query.Target, + Limit: quota.Limit, + UserId: query.UserId, + Used: used, + } + + return nil + }) +} + +func (ss *SQLStore) GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { + quotas := make([]*models.Quota, 0) + if err := sess.Table("quota").Where("user_id=? AND org_id=0", query.UserId).Find("as); err != nil { + return err + } + + defaultQuotas := setting.Quota.User.ToMap() + + seenTargets := make(map[string]bool) + for _, q := range quotas { + seenTargets[q.Target] = true + } + + for t, v := range defaultQuotas { + if _, ok := seenTargets[t]; !ok { + quotas = append(quotas, &models.Quota{ + UserId: query.UserId, + Target: t, + Limit: v, + }) + } + } + + result := make([]*models.UserQuotaDTO, len(quotas)) + for i, q := range quotas { + var used int64 + if q.Target != alertRuleTarget || query.UnifiedAlertingEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(q.Target)) + resp := make([]*targetCount, 0) + if err := sess.SQL(rawSQL, q.UserId).Find(&resp); err != nil { + return err + } + used = resp[0].Count + } + result[i] = &models.UserQuotaDTO{ + Target: q.Target, + Limit: q.Limit, + UserId: q.UserId, + Used: used, + } + } + query.Result = result + return nil + }) +} + +func (ss *SQLStore) UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error { + return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { + // Check if quota is already defined in the DB + quota := models.Quota{ + Target: cmd.Target, + UserId: cmd.UserId, + } + has, err := sess.Get("a) + if err != nil { + return err + } + quota.Updated = time.Now() + quota.Limit = cmd.Limit + if !has { + quota.Created = time.Now() + // No quota in the DB for this target, so create a new one. + if _, err := sess.Insert("a); err != nil { + return err + } + } else { + // update existing quota entry in the DB. + _, err := sess.ID(quota.Id).Update("a) + if err != nil { + return err + } + } + + return nil + }) +} + +func (ss *SQLStore) GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error { + return ss.WithDbSession(ctx, func(sess *DBSession) error { + var used int64 + + if query.Target == filesTarget { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", + dialect.Quote("file")) + + notFolderCondition := fmt.Sprintf(" WHERE path NOT LIKE '%s'", "%/") + resp := make([]*targetCount, 0) + if err := sess.SQL(rawSQL + notFolderCondition).Find(&resp); err != nil { + return err + } + used = resp[0].Count + } else if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", + dialect.Quote(query.Target)) + + if query.Target == dashboardTarget { + rawSQL += fmt.Sprintf(" WHERE is_folder=%s", dialect.BooleanStr(false)) + } + // removing service accounts from count + if query.Target == dialect.Quote("user") { + rawSQL += fmt.Sprintf(" WHERE is_service_account=%s", dialect.BooleanStr(false)) + } + resp := make([]*targetCount, 0) + if err := sess.SQL(rawSQL).Find(&resp); err != nil { + return err + } + used = resp[0].Count + } + + query.Result = &models.GlobalQuotaDTO{ + Target: query.Target, + Limit: query.Default, + Used: used, + } + + return nil + }) +} diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go new file mode 100644 index 00000000000..e58b42adf8d --- /dev/null +++ b/pkg/services/sqlstore/quota_test.go @@ -0,0 +1,301 @@ +package sqlstore + +import ( + "context" + "testing" + "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + sqlStore := InitTestDB(t) + userId := int64(1) + orgId := int64(0) + + setting.Quota = setting.QuotaSettings{ + Enabled: true, + Org: &setting.OrgQuota{ + User: 5, + Dashboard: 5, + DataSource: 5, + ApiKey: 5, + AlertRule: 5, + }, + User: &setting.UserQuota{ + Org: 5, + }, + Global: &setting.GlobalQuota{ + Org: 5, + User: 5, + Dashboard: 5, + DataSource: 5, + ApiKey: 5, + Session: 5, + AlertRule: 5, + }, + } + createUserCmd := user.CreateUserCommand{ + Name: "TestUser", + OrgID: orgId, + SkipOrgSetup: true, + } + user, err := sqlStore.CreateUser(context.Background(), createUserCmd) + require.NoError(t, err) + // create a new org and add user_id 1 as admin. + // we will then have an org with 1 user. and a user + // with 1 org. + userCmd := models.CreateOrgCommand{ + Name: "TestOrg", + UserId: user.ID, + } + + err = sqlStore.CreateOrg(context.Background(), &userCmd) + require.NoError(t, err) + orgId = userCmd.Result.Id + + t.Run("Given saved org quota for users", func(t *testing.T) { + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 10, + } + err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { + query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(10), query.Result.Limit) + }) + + t.Run("Should be able to get default quota by org id and target", func(t *testing.T) { + query := models.GetOrgQuotaByTargetQuery{OrgId: 123, Target: "org_user", Default: 11} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(11), query.Result.Limit) + }) + + t.Run("Should be able to get used org quota when rows exist", func(t *testing.T) { + query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 11} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(1), query.Result.Used) + }) + + t.Run("Should be able to get used org quota when no rows exist", func(t *testing.T) { + query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "org_user", Default: 11} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(0), query.Result.Used) + }) + + t.Run("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { + query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "alert", Default: 11} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(0), query.Result.Used) + }) + + t.Run("Should be able to quota list for org", func(t *testing.T) { + query := models.GetOrgQuotasQuery{OrgId: orgId} + err = sqlStore.GetOrgQuotas(context.Background(), &query) + + require.NoError(t, err) + require.Len(t, query.Result, 5) + for _, res := range query.Result { + limit := int64(5) // default quota limit + used := int64(0) + if res.Target == "org_user" { + limit = 10 // customized quota limit. + used = 1 + } + require.Equal(t, limit, res.Limit) + require.Equal(t, used, res.Used) + } + }) + }) + + t.Run("Given saved org quota for dashboards", func(t *testing.T) { + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: dashboardTarget, + Limit: 10, + } + err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { + query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: dashboardTarget, Default: 1} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(10), query.Result.Limit) + require.Equal(t, int64(0), query.Result.Used) + }) + }) + + t.Run("Given saved user quota for org", func(t *testing.T) { + userQuotaCmd := models.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 10, + } + err := sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved quota by user id and target", func(t *testing.T) { + query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(10), query.Result.Limit) + }) + + t.Run("Should be able to get default quota by user id and target", func(t *testing.T) { + query := models.GetUserQuotaByTargetQuery{UserId: 9, Target: "org_user", Default: 11} + err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(11), query.Result.Limit) + }) + + t.Run("Should be able to get used user quota when rows exist", func(t *testing.T) { + query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 11} + err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(1), query.Result.Used) + }) + + t.Run("Should be able to get used user quota when no rows exist", func(t *testing.T) { + query := models.GetUserQuotaByTargetQuery{UserId: 2, Target: "org_user", Default: 11} + err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, int64(0), query.Result.Used) + }) + + t.Run("Should be able to quota list for user", func(t *testing.T) { + query := models.GetUserQuotasQuery{UserId: userId} + err = sqlStore.GetUserQuotas(context.Background(), &query) + + require.NoError(t, err) + require.Len(t, query.Result, 1) + require.Equal(t, int64(10), query.Result[0].Limit) + require.Equal(t, int64(1), query.Result[0].Used) + }) + }) + + t.Run("Should be able to global user quota", func(t *testing.T) { + query := models.GetGlobalQuotaByTargetQuery{Target: "user", Default: 5} + err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + + require.Equal(t, int64(5), query.Result.Limit) + require.Equal(t, int64(1), query.Result.Used) + }) + + t.Run("Should be able to global org quota", func(t *testing.T) { + query := models.GetGlobalQuotaByTargetQuery{Target: "org", Default: 5} + err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + + require.Equal(t, int64(5), query.Result.Limit) + require.Equal(t, int64(1), query.Result.Used) + }) + + t.Run("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { + query := models.GetGlobalQuotaByTargetQuery{Target: "alert_rule", Default: 5} + err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + + require.Equal(t, int64(5), query.Result.Limit) + require.Equal(t, int64(0), query.Result.Used) + }) + + t.Run("Should be able to global dashboard quota", func(t *testing.T) { + query := models.GetGlobalQuotaByTargetQuery{Target: dashboardTarget, Default: 5} + err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + + require.Equal(t, int64(5), query.Result.Limit) + require.Equal(t, int64(0), query.Result.Used) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + t.Run("Should org quota updating is successful even if it called multiple time", func(t *testing.T) { + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 5, + } + err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) + require.NoError(t, err) + + query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + require.Equal(t, int64(5), query.Result.Limit) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + orgCmd = models.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 10, + } + err = sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) + require.NoError(t, err) + + query = models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + require.Equal(t, int64(10), query.Result.Limit) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + t.Run("Should user quota updating is successful even if it called multiple time", func(t *testing.T) { + userQuotaCmd := models.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 5, + } + err := sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + require.Equal(t, int64(5), query.Result.Limit) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + userQuotaCmd = models.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 10, + } + err = sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + query = models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + require.Equal(t, int64(10), query.Result.Limit) + }) +} diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 16ca3e885aa..463ad05b919 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -23,6 +23,13 @@ type Store interface { GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error WithDbSession(ctx context.Context, callback DBTransactionFunc) error WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error + GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error + GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error + UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error + GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error + GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error + UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error + GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error WithTransactionalDbSession(ctx context.Context, callback DBTransactionFunc) error InTransaction(ctx context.Context, fn func(ctx context.Context) error) error Migrate(bool) error diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index cc3beab7d10..61eb97c4239 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -59,11 +58,6 @@ type CreateFolderCmd struct { Path string `json:"path"` } -const ( - QuotaTargetSrv quota.TargetSrv = "store" - QuotaTarget quota.Target = "file" -) - type StorageService interface { registry.BackgroundService @@ -103,7 +97,7 @@ func ProvideService( features featuremgmt.FeatureToggles, cfg *setting.Cfg, quotaService quota.Service, -) (StorageService, error) { +) StorageService { settings, err := LoadStorageConfig(cfg, features) if err != nil { grafanaStorageLogger.Warn("error loading storage config", "error", err) @@ -265,37 +259,7 @@ func ProvideService( s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg) s.quotaService = quotaService s.cfg = settings - - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return nil, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: QuotaTargetSrv, - DefaultLimits: defaultLimits, - Reporter: s.Usage, - }); err != nil { - return nil, err - } - - return s, nil -} - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.File) - return limits, nil + return s } func createSystemBrandingPathFilter() filestorage.PathFilter { @@ -365,32 +329,6 @@ func (s *standardStorageService) Read(ctx context.Context, user *user.SignedInUs return s.tree.GetFile(ctx, getOrgId(user), path) } -func (s *standardStorageService) Usage(ctx context.Context, ScopeParameters *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - - err := s.sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - type result struct { - Count int64 - } - r := result{} - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM file WHERE path NOT LIKE '%s'", "%/") - - if _, err := sess.SQL(rawSQL).Get(&r); err != nil { - return err - } - - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) - if err != nil { - return err - } - u.Set(tag, r.Count) - - return nil - }) - - return u, err -} - type UploadRequest struct { Contents []byte Path string @@ -457,7 +395,7 @@ func (s *standardStorageService) Upload(ctx context.Context, user *user.SignedIn func (s *standardStorageService) checkFileQuota(ctx context.Context, path string) error { // assumes we are only uploading to the SQL database - TODO: refactor once we introduce object stores - quotaReached, err := s.quotaService.CheckQuotaReached(ctx, QuotaTargetSrv, nil) + quotaReached, err := s.quotaService.CheckQuotaReached(ctx, "file", nil) if err != nil { grafanaStorageLogger.Error("failed while checking upload quota", "path", path, "error", err) return ErrUploadInternalError diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index c74b744af16..650c3dceefc 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -118,7 +118,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } - store.quotaService = quotatest.New(false, nil) + store.quotaService = quotatest.NewQuotaServiceFake() return store, mockStorage, storageName } @@ -297,7 +297,7 @@ func TestContentRootWithNestedStorage(t *testing.T) { store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } - store.quotaService = quotatest.New(false, nil) + store.quotaService = quotatest.NewQuotaServiceFake() fileName := "file.jpg" tests := []struct { diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 88951c1cc5a..b5d66f1b360 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -357,8 +357,3 @@ type SearchUserFilter interface { } type FilterHandler func(params []string) (Filter, error) - -const ( - QuotaTargetSrv string = "user" - QuotaTarget string = "user" -) diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index 53deaf17c41..c368be1389c 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -11,7 +11,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -38,8 +37,6 @@ type store interface { BatchDisableUsers(context.Context, *user.BatchDisableUsersCommand) error Disable(context.Context, *user.DisableUserCommand) error Search(context.Context, *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) - - Count(ctx context.Context) (int64, error) } type sqlStore struct { @@ -464,22 +461,6 @@ func (ss *sqlStore) UpdatePermissions(ctx context.Context, userID int64, isAdmin }) } -func (ss *sqlStore) Count(ctx context.Context) (int64, error) { - type result struct { - Count int64 - } - - r := result{} - err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s WHERE is_service_account=%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().BooleanStr(false)) - if _, err := sess.SQL(rawSQL).Get(&r); err != nil { - return err - } - return nil - }) - return r.Count, err -} - // validateOneAdminLeft validate that there is an admin user left func validateOneAdminLeft(ctx context.Context, sess *db.Session) error { count, err := sess.Where("is_admin=?", true).Count(&user.User{}) diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index f2250a5245d..96dab700d94 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -33,44 +32,15 @@ func ProvideService( cfg *setting.Cfg, teamService team.Service, cacheService *localcache.CacheService, - quotaService quota.Service, -) (user.Service, error) { +) user.Service { store := ProvideStore(db, cfg) - s := &Service{ + return &Service{ store: &store, orgService: orgService, cfg: cfg, teamService: teamService, cacheService: cacheService, } - - defaultLimits, err := readQuotaConfig(cfg) - if err != nil { - return s, err - } - - if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: quota.TargetSrv(user.QuotaTargetSrv), - DefaultLimits: defaultLimits, - Reporter: s.Usage, - }); err != nil { - return s, err - } - return s, nil -} - -func (s *Service) Usage(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - if used, err := s.store.Count(ctx); err != nil { - return u, err - } else { - tag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) - if err != nil { - return u, err - } - u.Set(tag, used) - } - return u, nil } func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { @@ -334,19 +304,3 @@ func (s *Service) GetProfile(ctx context.Context, query *user.GetUserProfileQuer result, err := s.store.GetProfile(ctx, query) return result, err } - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } - - globalQuotaTag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.User) - return limits, nil -} diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index aadd510e2ad..a371c74789d 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -252,7 +252,3 @@ func (f *FakeUserStore) Disable(ctx context.Context, cmd *user.DisableUserComman func (f *FakeUserStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { return f.ExpectedSearchUserQueryResult, f.ExpectedError } - -func (f *FakeUserStore) Count(ctx context.Context) (int64, error) { - return 0, nil -} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 44a79754cfd..cf48a800f44 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -153,6 +153,9 @@ var ( LDAPAllowSignup bool LDAPActiveSyncEnabled bool + // Quota + Quota QuotaSettings + // Alerting AlertingEnabled *bool ExecuteAlerts bool @@ -419,12 +422,12 @@ type Cfg struct { LDAPSkipOrgRoleSync bool LDAPAllowSignup bool + Quota QuotaSettings + DefaultTheme string DefaultLocale string HomePage string - Quota QuotaSettings - AutoAssignOrg bool AutoAssignOrgId int AutoAssignOrgRole string @@ -1050,12 +1053,11 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.readAzureSettings() cfg.readSessionConfig() cfg.readSmtpSettings() + cfg.readQuotaSettings() if err := cfg.readAnnotationSettings(); err != nil { return err } - cfg.readQuotaSettings() - cfg.readExpressionsSettings() if err := cfg.readGrafanaEnvironmentMetrics(); err != nil { return err diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go index 053adb74662..b3cd6d01115 100644 --- a/pkg/setting/setting_quota.go +++ b/pkg/setting/setting_quota.go @@ -1,5 +1,9 @@ package setting +import ( + "reflect" +) + type OrgQuota struct { User int64 `target:"org_user"` DataSource int64 `target:"data_source"` @@ -23,17 +27,45 @@ type GlobalQuota struct { File int64 `target:"file"` } +func (q *OrgQuota) ToMap() map[string]int64 { + return quotaToMap(*q) +} + +func (q *UserQuota) ToMap() map[string]int64 { + return quotaToMap(*q) +} + +func quotaToMap(q interface{}) map[string]int64 { + qMap := make(map[string]int64) + typ := reflect.TypeOf(q) + val := reflect.ValueOf(q) + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + name := field.Tag.Get("target") + if name == "" { + name = field.Name + } + if name == "-" { + continue + } + value := val.Field(i) + qMap[name] = value.Int() + } + return qMap +} + type QuotaSettings struct { Enabled bool - Org OrgQuota - User UserQuota - Global GlobalQuota + Org *OrgQuota + User *UserQuota + Global *GlobalQuota } func (cfg *Cfg) readQuotaSettings() { // set global defaults. quota := cfg.Raw.Section("quota") - cfg.Quota.Enabled = quota.Key("enabled").MustBool(false) + Quota.Enabled = quota.Key("enabled").MustBool(false) var alertOrgQuota int64 var alertGlobalQuota int64 @@ -42,7 +74,7 @@ func (cfg *Cfg) readQuotaSettings() { alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1) } // per ORG Limits - cfg.Quota.Org = OrgQuota{ + Quota.Org = &OrgQuota{ User: quota.Key("org_user").MustInt64(10), DataSource: quota.Key("org_data_source").MustInt64(10), Dashboard: quota.Key("org_dashboard").MustInt64(10), @@ -51,12 +83,12 @@ func (cfg *Cfg) readQuotaSettings() { } // per User limits - cfg.Quota.User = UserQuota{ + Quota.User = &UserQuota{ Org: quota.Key("user_org").MustInt64(10), } // Global Limits - cfg.Quota.Global = GlobalQuota{ + Quota.Global = &GlobalQuota{ User: quota.Key("global_user").MustInt64(-1), Org: quota.Key("global_org").MustInt64(-1), DataSource: quota.Key("global_data_source").MustInt64(-1), @@ -66,4 +98,6 @@ func (cfg *Cfg) readQuotaSettings() { File: quota.Key("global_file").MustInt64(-1), AlertRule: alertGlobalQuota, } + + cfg.Quota = Quota } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 5755d9b6497..aaf7afb0693 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -1877,8 +1878,6 @@ func TestQuota(t *testing.T) { // Create a user to make authenticated requests createUser(t, store, user.CreateUserCommand{ - // needs permission to update org quota - IsAdmin: true, DefaultOrgRole: string(org.RoleEditor), Password: "password", Login: "grafana", @@ -1919,10 +1918,30 @@ func TestQuota(t *testing.T) { // check quota limits t.Run("when quota limit exceed creating new rule should fail", func(t *testing.T) { // get existing org quota - limit, used := apiClient.GetOrgQuotaLimits(t, 1) - apiClient.UpdateAlertRuleOrgQuota(t, 1, used) + query := models.GetOrgQuotaByTargetQuery{OrgId: 1, Target: "alert_rule"} + err = store.GetOrgQuotaByTarget(context.Background(), &query) + require.NoError(t, err) + used := query.Result.Used + limit := query.Result.Limit + + // set org quota limit to equal used + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: 1, + Target: "alert_rule", + Limit: used, + } + err := store.UpdateOrgQuota(context.Background(), &orgCmd) + require.NoError(t, err) + t.Cleanup(func() { - apiClient.UpdateAlertRuleOrgQuota(t, 1, limit) + // reset org quota to original value + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: 1, + Target: "alert_rule", + Limit: limit, + } + err := store.UpdateOrgQuota(context.Background(), &orgCmd) + require.NoError(t, err) }) // try to create an alert rule diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index f13443b5d2d..0b2540a7628 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -16,7 +16,6 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util" ) @@ -203,60 +202,6 @@ func (a apiClient) CreateFolder(t *testing.T, uID string, title string) { a.ReloadCachedPermissions(t) } -func (a apiClient) GetOrgQuotaLimits(t *testing.T, orgID int64) (int64, int64) { - t.Helper() - - u := fmt.Sprintf("%s/api/orgs/%d/quotas", a.url, orgID) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - defer func() { - _ = resp.Body.Close() - }() - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - results := []quota.QuotaDTO{} - require.NoError(t, json.Unmarshal(b, &results)) - - var limit int64 = 0 - var used int64 = 0 - for _, q := range results { - if q.Target != string(ngmodels.QuotaTargetSrv) { - continue - } - limit = q.Limit - used = q.Used - } - return limit, used -} - -func (a apiClient) UpdateAlertRuleOrgQuota(t *testing.T, orgID int64, limit int64) { - t.Helper() - buf := bytes.Buffer{} - enc := json.NewEncoder(&buf) - err := enc.Encode("a.UpdateQuotaCmd{ - Target: "alert_rule", - Limit: limit, - OrgID: orgID, - }) - require.NoError(t, err) - - u := fmt.Sprintf("%s/api/orgs/%d/quotas/alert_rule", a.url, orgID) - // nolint:gosec - client := &http.Client{} - req, err := http.NewRequest(http.MethodPut, u, &buf) - require.NoError(t, err) - req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) - require.NoError(t, err) - defer func() { - _ = resp.Body.Close() - }() - assert.Equal(t, http.StatusOK, resp.StatusCode) -} - func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (int, string) { t.Helper() buf := bytes.Buffer{} diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index beee540e81b..263fc24a828 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -16,14 +16,16 @@ import ( datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" - "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) func TestHandleRequest(t *testing.T) { + cfg := &setting.Cfg{} + t.Run("Should invoke plugin manager QueryData when handling request for query", func(t *testing.T) { origOAuthIsOAuthPassThruEnabledFunc := oAuthIsOAuthPassThruEnabledFunc oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *datasources.DataSource) bool { @@ -44,10 +46,7 @@ func TestHandleRequest(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) datasourcePermissions := acmock.NewMockedPermissionsService() - quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions, quotaService) - require.NoError(t, err) - + dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions) s := ProvideService(client, nil, dsService) ds := &datasources.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} From e4c394dfcdbf80936ce3b9669ed722d90bd3bb2c Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Tue, 8 Nov 2022 10:10:09 +0000 Subject: [PATCH 107/926] Correlations: add tracking for add, update, delete, and details expanded (#58239) * Correlations: add tracking for add, update, delete, and details expanded * add tests * change delete event * rename handlers --- .../correlations/CorrelationsPage.test.tsx | 49 ++++++++++++--- .../correlations/CorrelationsPage.tsx | 63 ++++++++++++++++--- .../features/correlations/useCorrelations.ts | 2 +- 3 files changed, 96 insertions(+), 18 deletions(-) diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx index 81827aee913..2de67a10d60 100644 --- a/public/app/features/correlations/CorrelationsPage.test.tsx +++ b/public/app/features/correlations/CorrelationsPage.test.tsx @@ -8,7 +8,14 @@ import { MockDataSourceApi } from 'test/mocks/datasource_srv'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { DataSourcePluginMeta } from '@grafana/data'; -import { BackendSrv, FetchError, FetchResponse, setDataSourceSrv, BackendSrvRequest } from '@grafana/runtime'; +import { + BackendSrv, + FetchError, + FetchResponse, + setDataSourceSrv, + BackendSrvRequest, + reportInteraction, +} from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; @@ -47,12 +54,6 @@ function createFetchError(overrides?: DeepPartial): FetchError { ); } -jest.mock('app/core/services/context_srv'); - -const mocks = { - contextSrv: jest.mocked(contextSrv), -}; - const renderWithContext = async ( datasources: ConstructorParameters[0] = {}, correlations: Correlation[] = [] @@ -217,6 +218,20 @@ const renderWithContext = async ( return renderResult; }; +jest.mock('app/core/services/context_srv'); + +const mocks = { + contextSrv: jest.mocked(contextSrv), + reportInteraction: jest.fn(), +}; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: (...args: Parameters) => { + mocks.reportInteraction(...args); + }, +})); + beforeAll(() => { mocks.contextSrv.hasPermission.mockImplementation(() => true); }); @@ -254,6 +269,10 @@ describe('CorrelationsPage', () => { }); }); + afterEach(() => { + mocks.reportInteraction.mockClear(); + }); + it('shows CTA', async () => { // insert form should not be present expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument(); @@ -305,12 +324,18 @@ describe('CorrelationsPage', () => { // Waits for the form to be removed, meaning the correlation got successfully saved await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /add$/i })); + expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_added'); + // the table showing correlations should have appeared expect(screen.getByRole('table')).toBeInTheDocument(); }); }); describe('With correlations', () => { + afterEach(() => { + mocks.reportInteraction.mockClear(); + }); + let queryRowsByCellValue: (columnName: Matcher, textValue: Matcher) => HTMLTableRowElement[]; let getHeaderByName: (columnName: Matcher) => HTMLTableCellElement; let queryCellsByColumnName: (columnName: Matcher) => HTMLTableCellElement[]; @@ -430,6 +455,8 @@ describe('CorrelationsPage', () => { // the form should get removed after successful submissions await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /add$/i })); + + expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_added'); }); it('correctly closes the form when clicking on the close icon', async () => { @@ -460,6 +487,8 @@ describe('CorrelationsPage', () => { fireEvent.click(confirmButton); await waitForElementToBeRemoved(() => screen.queryByRole('cell', { name: /some label$/i })); + + expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_deleted'); }); it('correctly edits correlations', async () => { @@ -468,6 +497,8 @@ describe('CorrelationsPage', () => { const rowExpanderButton = within(tableRows[0]).getByRole('button', { name: /toggle row expanded/i }); fireEvent.click(rowExpanderButton); + expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_details_expanded'); + await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i)); fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'edited label' } }); @@ -482,6 +513,8 @@ describe('CorrelationsPage', () => { await waitFor(() => { expect(screen.queryByRole('cell', { name: /edited label$/i })).toBeInTheDocument(); }); + + expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_edited'); }); }); @@ -526,6 +559,8 @@ describe('CorrelationsPage', () => { fireEvent.click(rowExpanderButton); + expect(mocks.reportInteraction).toHaveBeenLastCalledWith('grafana_correlations_details_expanded'); + // wait for the form to be rendered and query editor to be mounted await waitForElementToBeRemoved(() => screen.queryByText(/loading query editor/i)); diff --git a/public/app/features/correlations/CorrelationsPage.tsx b/public/app/features/correlations/CorrelationsPage.tsx index 5ee331be916..730b32c9952 100644 --- a/public/app/features/correlations/CorrelationsPage.tsx +++ b/public/app/features/correlations/CorrelationsPage.tsx @@ -4,7 +4,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { CellProps, SortByFn } from 'react-table'; import { GrafanaTheme2 } from '@grafana/data'; -import { isFetchError } from '@grafana/runtime'; +import { isFetchError, reportInteraction } from '@grafana/runtime'; import { Badge, Button, DeleteButton, HorizontalGroup, LoadingPlaceholder, useStyles2, Alert } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; @@ -15,6 +15,7 @@ import { AddCorrelationForm } from './Forms/AddCorrelationForm'; import { EditCorrelationForm } from './Forms/EditCorrelationForm'; import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA'; import { Column, Table } from './components/Table'; +import type { RemoveCorrelationParams } from './types'; import { CorrelationData, useCorrelations } from './useCorrelations'; const sortDatasource: SortByFn = (a, b, column) => @@ -43,11 +44,31 @@ export default function CorrelationsPage() { const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite); - const handleAdd = useCallback(() => { + const handleAdded = useCallback(() => { + reportInteraction('grafana_correlations_added'); fetchCorrelations(); setIsAdding(false); }, [fetchCorrelations]); + const handleUpdated = useCallback(() => { + reportInteraction('grafana_correlations_edited'); + fetchCorrelations(); + }, [fetchCorrelations]); + + const handleDelete = useCallback( + (params: RemoveCorrelationParams) => { + remove.execute(params); + }, + [remove] + ); + + // onDelete - triggers when deleting a correlation + useEffect(() => { + if (remove.value) { + reportInteraction('grafana_correlations_deleted'); + } + }, [remove.value]); + useEffect(() => { if (!remove.error && !remove.loading && remove.value) { fetchCorrelations(); @@ -66,11 +87,11 @@ export default function CorrelationsPage() { !readOnly && ( remove.execute({ sourceUID, uid })} + onConfirm={() => handleDelete({ sourceUID, uid })} closeOnConfirm /> ), - [remove] + [handleDelete] ); const columns = useMemo>>( @@ -142,15 +163,15 @@ export default function CorrelationsPage() { ) } - {isAdding && setIsAdding(false)} onCreated={handleAdd} />} + {isAdding && setIsAdding(false)} onCreated={handleAdded} />} {data && data.length >= 1 && ( ( - ( + )} columns={columns} @@ -164,6 +185,28 @@ export default function CorrelationsPage() { ); } +interface ExpandedRowProps { + correlation: CorrelationData; + readOnly: boolean; + onUpdated: () => void; +} +function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) { + useEffect( + () => reportInteraction('grafana_correlations_details_expanded'), + // we only want to fire this on first render + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + ); +} + const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({ root: css` display: flex; diff --git a/public/app/features/correlations/useCorrelations.ts b/public/app/features/correlations/useCorrelations.ts index 88aad32fb06..b013a0d99d6 100644 --- a/public/app/features/correlations/useCorrelations.ts +++ b/public/app/features/correlations/useCorrelations.ts @@ -48,7 +48,7 @@ export const useCorrelations = () => { [backend] ); - const [removeInfo, remove] = useAsyncFn<(params: RemoveCorrelationParams) => Promise>( + const [removeInfo, remove] = useAsyncFn<(params: RemoveCorrelationParams) => Promise<{ message: string }>>( ({ sourceUID, uid }) => backend.delete(`/api/datasources/uid/${sourceUID}/correlations/${uid}`), [backend] ); From 5cfd983cc2a04c62267ed7384d390f5bee9dcb88 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Tue, 8 Nov 2022 10:27:54 +0000 Subject: [PATCH 108/926] AzureMonitor - E2E tests drone update (#57100) * Update e2e command with video flag * Add Cloud Plugins E2E tests to drone * Update env variable names * Add vault Azure secrets * Update e2e steps * Update secrets path * Update image and rebuild drone file * Readd drone changes * Rebuild drone * Remake drone * Correct reference to secret * Remake drone file * Remove unneeded step * Clear values in Arg query --- .drone.yml | 70 ++++++++++++++++++- e2e/cloud-plugins-suite/azure-monitor.spec.ts | 5 +- e2e/run-suite | 1 + pkg/build/cmd/e2etests.go | 3 +- pkg/build/cmd/main.go | 5 ++ scripts/drone/pipelines/build.star | 2 + scripts/drone/steps/lib.star | 35 ++++++++++ scripts/drone/vault.star | 6 ++ 8 files changed, 124 insertions(+), 3 deletions(-) diff --git a/.drone.yml b/.drone.yml index 414743f8b8c..1979761236f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -507,6 +507,31 @@ steps: HOST: grafana-server image: cypress/included:9.5.1-node16.14.0-slim-chrome99-ff97 name: end-to-end-tests-various-suite +- commands: + - cd / + - ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH} + depends_on: + - grafana-server + environment: + AZURE_SP_APP_ID: + from_secret: azure_sp_app_id + AZURE_SP_PASSWORD: + from_secret: azure_sp_app_pw + AZURE_TENANT: + from_secret: azure_tenant + CYPRESS_CI: "true" + GITHUB_TOKEN: + from_secret: github_token_pr + HOST: grafana-server + image: us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e:latest + name: end-to-end-tests-cloud-plugins-suite-azure + when: + paths: + include: + - pkg/tsdb/azuremonitor/** + - public/app/plugins/datasource/grafana-azure-monitor-datasource/** + repo: + - grafana/grafana - commands: - apt-get update - apt-get install -yq zip @@ -1321,6 +1346,31 @@ steps: HOST: grafana-server image: cypress/included:9.5.1-node16.14.0-slim-chrome99-ff97 name: end-to-end-tests-various-suite +- commands: + - cd / + - ./cpp-e2e/scripts/ci-run.sh azure ${DRONE_SOURCE_BRANCH} + depends_on: + - grafana-server + environment: + AZURE_SP_APP_ID: + from_secret: azure_sp_app_id + AZURE_SP_PASSWORD: + from_secret: azure_sp_app_pw + AZURE_TENANT: + from_secret: azure_tenant + CYPRESS_CI: "true" + GITHUB_TOKEN: + from_secret: github_token_pr + HOST: grafana-server + image: us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e:latest + name: end-to-end-tests-cloud-plugins-suite-azure + when: + paths: + include: + - pkg/tsdb/azuremonitor/** + - public/app/plugins/datasource/grafana-azure-monitor-datasource/** + repo: + - grafana/grafana - commands: - apt-get update - apt-get install -yq zip @@ -5409,6 +5459,24 @@ get: kind: secret name: gcp_upload_artifacts_key --- +get: + name: application_id + path: infra/data/ci/datasources/cpp-azure-resourcemanager-credentials +kind: secret +name: azure_sp_app_id +--- +get: + name: application_secret + path: infra/data/ci/datasources/cpp-azure-resourcemanager-credentials +kind: secret +name: azure_sp_app_pw +--- +get: + name: tenant_id + path: infra/data/ci/datasources/cpp-azure-resourcemanager-credentials +kind: secret +name: azure_tenant +--- get: name: public-key path: infra/data/ci/packages-publish/gpg @@ -5446,6 +5514,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 7a173a96edd8b0495105d526b95121599fe2f7fba715bdf2a96073a2af5eca7d +hmac: d703e0a1b27d8396587f430f4175ec924dd51baf4e5b89ff49c94560b9452631 ... diff --git a/e2e/cloud-plugins-suite/azure-monitor.spec.ts b/e2e/cloud-plugins-suite/azure-monitor.spec.ts index db8fc29c235..351c8fb8759 100644 --- a/e2e/cloud-plugins-suite/azure-monitor.spec.ts +++ b/e2e/cloud-plugins-suite/azure-monitor.spec.ts @@ -183,7 +183,10 @@ e2e.scenario({ queriesForm: () => { e2eSelectors.queryEditor.header.select().find('input').type('Azure Resource Graph{enter}'); e2e().wait(1000); // Need to wait for code editor to completely load - e2e().get('[aria-label="Remove Primary Subscription"]').click(); + e2eSelectors.queryEditor.argsQueryEditor.subscriptions + .input() + .find('[aria-label="select-clear-value"]') + .click(); e2eSelectors.queryEditor.argsQueryEditor.subscriptions.input().find('input').type('datasources{enter}'); e2e.components.CodeEditor.container().type( "Resources | where resourceGroup == 'cloud-plugins-e2e-test' | project name, resourceGroup" diff --git a/e2e/run-suite b/e2e/run-suite index 9aa620b20f3..9b61e3b2e8a 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -68,6 +68,7 @@ case "$1" in *) cypressConfig[integrationFolder]=../../e2e/"${args[0]}" cypressConfig[testFiles]=$testFilesForSingleSuite + cypressConfig[video]=${args[1]} ;; esac diff --git a/pkg/build/cmd/e2etests.go b/pkg/build/cmd/e2etests.go index 286fe8e8073..f377b08f874 100644 --- a/pkg/build/cmd/e2etests.go +++ b/pkg/build/cmd/e2etests.go @@ -15,6 +15,7 @@ func EndToEndTests(c *cli.Context) error { tries = c.Int("tries") suite = c.String("suite") host = c.String("host") + video = c.String("video") ) log.Printf("Running Grafana e2e tests") @@ -30,7 +31,7 @@ func EndToEndTests(c *cli.Context) error { for i := 0; i < tries; i++ { log.Printf("Running e2e test suite attempt #%d", i+1) //nolint:gosec - cmd := exec.Command("./e2e/run-suite", suite) + cmd := exec.Command("./e2e/run-suite", suite, video) cmd.Env = env cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index aa34b40cb25..46d8ef8f32a 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -56,6 +56,11 @@ func main() { Value: "grafana-server", Usage: "Specify the server host", }, + &cli.StringFlag{ + Name: "video", + Value: "true", + Usage: "Specify if videos should be recorded", + }, }, }, { diff --git a/scripts/drone/pipelines/build.star b/scripts/drone/pipelines/build.star index d428ae57c09..081c2a0e0d0 100644 --- a/scripts/drone/pipelines/build.star +++ b/scripts/drone/pipelines/build.star @@ -36,6 +36,7 @@ load( 'betterer_frontend_step', 'trigger_test_release', 'compile_build_cmd', + 'cloud_plugins_e2e_tests_step', ) load( @@ -75,6 +76,7 @@ def build_e2e(trigger, ver_mode, edition): e2e_tests_step('smoke-tests-suite', edition=edition), e2e_tests_step('panels-suite', edition=edition), e2e_tests_step('various-suite', edition=edition), + cloud_plugins_e2e_tests_step('cloud-plugins-suite', edition=edition, cloud='azure', trigger=trigger_oss), e2e_tests_artifacts(edition=edition), build_storybook_step(edition=edition, ver_mode=ver_mode), copy_packages_for_docker_step(), diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 9e0bdf78645..a21f288106b 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -731,6 +731,41 @@ def e2e_tests_step(suite, edition, port=3001, tries=None): ], } +def cloud_plugins_e2e_tests_step(suite, edition, cloud, port=3001, video="false", trigger=None): + environment = {} + when = {} + if trigger: + when = trigger + if cloud == 'azure': + environment = { + 'CYPRESS_CI': 'true', + 'HOST': 'grafana-server' + enterprise2_suffix(edition), + 'GITHUB_TOKEN': from_secret('github_token_pr'), + 'AZURE_SP_APP_ID': from_secret('azure_sp_app_id'), + 'AZURE_SP_PASSWORD': from_secret('azure_sp_app_pw'), + 'AZURE_TENANT': from_secret('azure_tenant') + } + when= dict(when, paths={ + 'include' : [ + 'pkg/tsdb/azuremonitor/**', + 'public/app/plugins/datasource/grafana-azure-monitor-datasource/**' + ] + }) + branch = "${DRONE_SOURCE_BRANCH}".replace("/", "-") + step = { + 'name': 'end-to-end-tests-{}-{}'.format(suite, cloud) + enterprise2_suffix(edition), + 'image': 'us-docker.pkg.dev/grafanalabs-dev/cloud-data-sources/e2e:latest', + 'depends_on': [ + 'grafana-server', + ], + 'environment': environment, + 'commands': [ + 'cd /', + './cpp-e2e/scripts/ci-run.sh {} {}'.format(cloud, branch) + ], + } + step = dict(step, when=when) + return step def build_docs_website_step(): return { diff --git a/scripts/drone/vault.star b/scripts/drone/vault.star index b30d486cfec..c1a4e25c5bb 100644 --- a/scripts/drone/vault.star +++ b/scripts/drone/vault.star @@ -3,6 +3,9 @@ github_token = 'github_token' drone_token = 'drone_token' prerelease_bucket = 'prerelease_bucket' gcp_upload_artifacts_key = 'gcp_upload_artifacts_key' +azure_sp_app_id = 'azure_sp_app_id' +azure_sp_app_pw = 'azure_sp_app_pw' +azure_tenant = 'azure_tenant' def from_secret(secret): return { @@ -26,6 +29,9 @@ def secrets(): vault_secret(drone_token, 'infra/data/ci/drone', 'machine-user-token'), vault_secret(prerelease_bucket, 'infra/data/ci/grafana/prerelease', 'bucket'), vault_secret(gcp_upload_artifacts_key, 'infra/data/ci/grafana/releng/artifacts-uploader-service-account', 'credentials.json'), + vault_secret(azure_sp_app_id, 'infra/data/ci/datasources/cpp-azure-resourcemanager-credentials', 'application_id'), + vault_secret(azure_sp_app_pw, 'infra/data/ci/datasources/cpp-azure-resourcemanager-credentials', 'application_secret'), + vault_secret(azure_tenant, 'infra/data/ci/datasources/cpp-azure-resourcemanager-credentials', 'tenant_id'), # Package publishing vault_secret('packages_gpg_public_key', 'infra/data/ci/packages-publish/gpg', 'public-key'), From af2f51f196829e05084080d23c939dcbd9f50670 Mon Sep 17 00:00:00 2001 From: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Tue, 8 Nov 2022 11:33:13 +0100 Subject: [PATCH 109/926] Folder: Add folder service implementation (#58182) * Folder: Add folder service implementation * Add Move * Add tests * Add new servie method and adjust Update, Delete and Move * Remove contains * GetTree return children of depth one --- pkg/services/folder/folderimpl/folder.go | 73 +++++++++++++++++++ pkg/services/folder/folderimpl/folder_test.go | 67 +++++++++++++++++ pkg/services/folder/folderimpl/sqlstore.go | 6 ++ pkg/services/folder/folderimpl/store_fake.go | 8 +- pkg/services/folder/model.go | 5 +- pkg/services/folder/service.go | 2 +- 6 files changed, 157 insertions(+), 4 deletions(-) diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index e94983e8de9..019164504a4 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -18,9 +18,12 @@ import ( "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) type Service struct { + store store + log log.Logger cfg *setting.Cfg dashboardService dashboards.DashboardService @@ -284,6 +287,76 @@ func (s *Service) DeleteFolder(ctx context.Context, user *user.SignedInUser, org return dashFolder, nil } +func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + if cmd.UID == "" { + cmd.UID = util.GenerateShortUID() + } + return s.store.Create(ctx, *cmd) +} + +func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + return s.store.Update(ctx, *cmd) +} + +func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + + foldr, err := s.Get(ctx, &folder.GetFolderQuery{ + UID: &cmd.UID, + OrgID: cmd.OrgID, + }) + if err != nil { + return nil, err + } + + return s.store.Update(ctx, folder.UpdateFolderCommand{ + Folder: foldr, + NewParentUID: &cmd.NewParentUID, + }) +} + +func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) (*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + // check if dashboard exists + + foldr, err := s.Get(ctx, &folder.GetFolderQuery{ + UID: &cmd.UID, + OrgID: cmd.OrgID, + }) + if err != nil { + return nil, err + } + err = s.store.Delete(ctx, cmd.UID, cmd.OrgID) + if err != nil { + return nil, err + } + return foldr, nil +} + +func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + return s.store.Get(ctx, *cmd) +} + +func (s *Service) GetParents(ctx context.Context, cmd *folder.GetParentsQuery) ([]*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + return s.store.GetParents(ctx, *cmd) +} + +func (s *Service) GetTree(ctx context.Context, cmd *folder.GetTreeQuery) ([]*folder.Folder, error) { + // check the flag, if old - do whatever did before + // for new only the store + return s.store.GetChildren(ctx, *cmd) +} + func (s *Service) MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error { return s.dashboardService.MakeUserAdmin(ctx, orgID, userID, folderID, setViewAndEditPermissions) } diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 7cb51195980..07a36ca9a7a 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashboardsvc "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -259,3 +260,69 @@ func TestIntegrationFolderService(t *testing.T) { }) }) } + +func TestFolderService(t *testing.T) { + folderStore := NewFakeStore() + folderService := &Service{ + store: folderStore, + } + t.Run("create folder", func(t *testing.T) { + folderStore.ExpectedFolder = &folder.Folder{} + res, err := folderService.Create(context.Background(), &folder.CreateFolderCommand{}) + require.NoError(t, err) + require.NotNil(t, res.UID) + }) + + t.Run("update folder", func(t *testing.T) { + folderStore.ExpectedFolder = &folder.Folder{} + _, err := folderService.Update(context.Background(), &folder.UpdateFolderCommand{}) + require.NoError(t, err) + }) + + t.Run("delete folder", func(t *testing.T) { + folderStore.ExpectedFolder = &folder.Folder{} + _, err := folderService.Delete(context.Background(), &folder.DeleteFolderCommand{}) + require.NoError(t, err) + }) + + t.Run("get folder", func(t *testing.T) { + folderStore.ExpectedFolder = &folder.Folder{} + _, err := folderService.Get(context.Background(), &folder.GetFolderQuery{}) + require.NoError(t, err) + }) + + t.Run("get parents folder", func(t *testing.T) { + folderStore.ExpectedFolder = &folder.Folder{} + _, err := folderService.GetParents(context.Background(), &folder.GetParentsQuery{}) + require.NoError(t, err) + }) + + t.Run("get children folder", func(t *testing.T) { + folderStore.ExpectedFolders = []*folder.Folder{ + { + UID: "test", + }, + { + UID: "test2", + }, + { + UID: "test3", + }, + { + UID: "test4", + }, + } + res, err := folderService.GetTree(context.Background(), + &folder.GetTreeQuery{ + UID: "test", + }) + require.NoError(t, err) + require.Equal(t, 4, len(res)) + }) + + t.Run("move folder", func(t *testing.T) { + folderStore.ExpectedFolder = &folder.Folder{} + _, err := folderService.Move(context.Background(), &folder.MoveFolderCommand{}) + require.NoError(t, err) + }) +} diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 52a2a6ffbde..5840bbba91c 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -120,6 +120,12 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) args = append(args, cmd.Folder.UID) } + if cmd.NewParentUID != nil { + columnsToUpdate = append(columnsToUpdate, "parent_uid = ?") + cmd.Folder.ParentUID = *cmd.NewParentUID + args = append(args, cmd.Folder.UID) + } + if len(columnsToUpdate) == 0 { return folder.ErrBadRequest.Errorf("no columns to update") } diff --git a/pkg/services/folder/folderimpl/store_fake.go b/pkg/services/folder/folderimpl/store_fake.go index e74c23784de..302acda3e24 100644 --- a/pkg/services/folder/folderimpl/store_fake.go +++ b/pkg/services/folder/folderimpl/store_fake.go @@ -12,6 +12,10 @@ type FakeStore struct { ExpectedError error } +func NewFakeStore() *FakeStore { + return &FakeStore{} +} + var _ store = (*FakeStore)(nil) func (f *FakeStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) { @@ -26,8 +30,8 @@ func (f *FakeStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand) return f.ExpectedFolder, f.ExpectedError } -func (f *FakeStore) Move(ctx context.Context, cmd folder.MoveFolderCommand) (*folder.Folder, error) { - return f.ExpectedFolder, f.ExpectedError +func (f *FakeStore) Move(ctx context.Context, cmd folder.MoveFolderCommand) error { + return f.ExpectedError } func (f *FakeStore) Get(ctx context.Context, cmd folder.GetFolderQuery) (*folder.Folder, error) { diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index e6f0a963010..3886b8471f4 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -60,6 +60,7 @@ type CreateFolderCommand struct { type UpdateFolderCommand struct { Folder *Folder `json:"folder"` // The extant folder NewUID *string `json:"uid" xorm:"uid"` + NewParentUID *string `json:"parent_uid" xorm:"parent_uid"` NewTitle *string `json:"title"` NewDescription *string `json:"description"` } @@ -69,12 +70,14 @@ type UpdateFolderCommand struct { type MoveFolderCommand struct { UID string `json:"uid"` NewParentUID string `json:"new_parent_uid"` + OrgID int64 `json:"orgId"` } // DeleteFolderCommand captures the information required by the folder service // to delete a folder. type DeleteFolderCommand struct { - UID string `json:"uid" xorm:"uid"` + UID string `json:"uid" xorm:"uid"` + OrgID int64 `json:"orgId" xorm:"org_id"` } // GetFolderQuery is used for all folder Get requests. Only one of UID, ID, or diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index 49904dd3c04..1d18ea17890 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -55,5 +55,5 @@ type NestedFolderService interface { // // The map keys are folder uids and the values are the list of child folders // for that parent. - GetTree(ctx context.Context, cmd *GetTreeQuery) (map[string][]*Folder, error) + GetTree(ctx context.Context, cmd *GetTreeQuery) ([]*Folder, error) } From ef7145e4aabd8fae9b485509dd071d95ae42e415 Mon Sep 17 00:00:00 2001 From: Kristin Laemmert Date: Tue, 8 Nov 2022 05:51:00 -0500 Subject: [PATCH 110/926] feat(nested folders): Add CountAlertRulesInFolder to ngalert store (#58269) * chore: refactor CountDashboardsInFolder to use the more efficient Count() sql function * feat(nested folders): Add CountAlertRulesInFolder to ngalert store This commit adds CountAlertRulesInFolder and a new model for the CountAlertRulesQuery. It returns a count of alert rules associated with a given orgID and parent folder UID. (the namespace referenced inside alert rules is the parent folder). I'm not sure where this belongs in the ngalert service, so that will come in a future commit. --- pkg/services/dashboards/database/database.go | 15 +-- pkg/services/dashboards/models.go | 8 +- pkg/services/ngalert/models/alert_rule.go | 6 ++ pkg/services/ngalert/store/alert_rule.go | 13 +++ pkg/services/ngalert/store/alert_rule_test.go | 92 ++++++++++++++----- 5 files changed, 101 insertions(+), 33 deletions(-) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 321e7eab7c0..045d2597239 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -1018,17 +1018,20 @@ func (d *DashboardStore) GetDashboardTags(ctx context.Context, query *models.Get }) } +// CountDashboardsInFolder returns a count of all dashboards associated with the +// given parent folder ID. +// // This will be updated to take CountDashboardsInFolderQuery as an argument and -// lookup dashboards using the ParentFolderUID when the NestedFolder -// implementation is complete. +// lookup dashboards using the ParentFolderUID when dashboards are associated with a parent folder UID instead of ID. func (d *DashboardStore) CountDashboardsInFolder( ctx context.Context, req *dashboards.CountDashboardsInFolderRequest) (int64, error) { - var dashboards = make([]*models.Dashboard, 0) - err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + var count int64 + var err error + err = d.store.WithDbSession(ctx, func(sess *db.Session) error { session := sess.In("folder_id", req.FolderID).In("org_id", req.OrgID). In("is_folder", d.store.GetDialect().BooleanStr(false)) - err := session.Find(&dashboards) + count, err = session.Count(&models.Dashboard{}) return err }) - return int64(len(dashboards)), err + return count, err } diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 21c184cff5c..a73dfb2457e 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -32,12 +32,12 @@ type DashboardSearchProjection struct { type CountDashboardsInFolderQuery struct { FolderUID string + OrgID int64 } -// Note for reviewers: I wasn't sure what to name this. It's not actually a DTO -// CountDashboardsInFolderRequest is the request passed from the service to the -// store layer. The FolderID will be replaced with FolderUID when dashboards are -// updated with parent folder UIDs. +// TODO: CountDashboardsInFolderRequest is the request passed from the service +// to the store layer. The FolderID will be replaced with FolderUID when +// dashboards are updated with parent folder UIDs. type CountDashboardsInFolderRequest struct { FolderID int64 OrgID int64 diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 1572cd7b850..0b56ec2f21d 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -354,6 +354,12 @@ type ListAlertRulesQuery struct { Result RulesGroup } +// CountAlertRulesQuery is the query for counting alert rules +type CountAlertRulesQuery struct { + OrgID int64 + NamespaceUID string +} + type GetAlertRulesForSchedulingQuery struct { PopulateFolders bool diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 1d2084857e3..592a50689b6 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -226,6 +226,19 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR }) } +// CountAlertRulesInFolder is a handler for retrieving the number of alert rules of +// specific organisation associated with a given namespace (parent folder). +func (st DBstore) CountAlertRulesInFolder(ctx context.Context, query *ngmodels.CountAlertRulesQuery) (int64, error) { + var count int64 + var err error + err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + q := sess.Table("alert_rule").Where("org_id = ?", query.OrgID).Where("namespace_uid = ?", query.NamespaceUID) + count, err = q.Count() + return err + }) + return count, err +} + // ListAlertRules is a handler for retrieving alert rules of specific organisation. func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error { return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 4fb12c06b66..a4d4be01b67 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -28,30 +28,9 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { BaseInterval: time.Duration(rand.Int63n(100)) * time.Second, }, } - createRule := func(t *testing.T) *models.AlertRule { - rule := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval))() - err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { - _, err := sess.Table(models.AlertRule{}).InsertOne(rule) - if err != nil { - return err - } - dbRule := &models.AlertRule{} - exist, err := sess.Table(models.AlertRule{}).ID(rule.ID).Get(dbRule) - if err != nil { - return err - } - if !exist { - return errors.New("cannot read inserted record") - } - rule = dbRule - return nil - }) - require.NoError(t, err) - return rule - } t.Run("should increase version", func(t *testing.T) { - rule := createRule(t) + rule := createRule(t, store) newRule := models.CopyRule(rule) newRule.Title = util.GenerateShortUID() err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ @@ -73,7 +52,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { }) t.Run("should fail due to optimistic locking if version does not match", func(t *testing.T) { - rule := createRule(t) + rule := createRule(t, store) rule.Version-- // simulate version discrepancy newRule := models.CopyRule(rule) @@ -150,3 +129,70 @@ func TestIntegration_getFilterByOrgsString(t *testing.T) { }) } } + +func TestIntegration_CountAlertRules(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + store := DBstore{SQLStore: sqlStore} + rule := createRule(t, store) + + tests := map[string]struct { + query *models.CountAlertRulesQuery + expected int64 + expectErr bool + }{ + "basic success": { + &models.CountAlertRulesQuery{ + NamespaceUID: rule.NamespaceUID, + OrgID: rule.OrgID, + }, + 1, + false, + }, + "successfully returning no results": { + &models.CountAlertRulesQuery{ + NamespaceUID: "probably not a uid we'd generate", + OrgID: rule.OrgID, + }, + 0, + false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + count, err := store.CountAlertRulesInFolder(context.Background(), test.query) + if test.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expected, count) + } + }) + } +} + +func createRule(t *testing.T, store DBstore) *models.AlertRule { + rule := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval))() + err := store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Table(models.AlertRule{}).InsertOne(rule) + if err != nil { + return err + } + dbRule := &models.AlertRule{} + exist, err := sess.Table(models.AlertRule{}).ID(rule.ID).Get(dbRule) + if err != nil { + return err + } + if !exist { + return errors.New("cannot read inserted record") + } + rule = dbRule + return nil + }) + require.NoError(t, err) + return rule +} From 0e87d27e5b6b81f520a7952f47390c8e2128da8c Mon Sep 17 00:00:00 2001 From: ying-jeanne <74549700+ying-jeanne@users.noreply.github.com> Date: Tue, 8 Nov 2022 12:36:18 +0100 Subject: [PATCH 111/926] Support folderUID in import dashboard service (#58415) * add folder service and get folderid * remove storage? --- .../dashboardimport/dashboardimport.go | 1 + .../dashboardimport/service/service.go | 21 ++++++++++++++++++- .../dashboardimport/service/service_test.go | 16 ++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/services/dashboardimport/dashboardimport.go b/pkg/services/dashboardimport/dashboardimport.go index 5f270d50366..e3fccb093ef 100644 --- a/pkg/services/dashboardimport/dashboardimport.go +++ b/pkg/services/dashboardimport/dashboardimport.go @@ -39,6 +39,7 @@ type ImportDashboardResponse struct { Slug string `json:"slug"` DashboardId int64 `json:"dashboardId"` FolderId int64 `json:"folderId"` + FolderUID string `json:"folderUid"` ImportedRevision int64 `json:"importedRevision"` Revision int64 `json:"revision"` Description string `json:"description"` diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index 2d969f92ced..f4da623cc75 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardimport/api" "github.com/grafana/grafana/pkg/services/dashboardimport/utils" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/quota" @@ -21,12 +22,13 @@ func ProvideService(routeRegister routing.RouteRegister, quotaService quota.Service, pluginDashboardService plugindashboards.Service, pluginStore plugins.Store, libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService, - ac accesscontrol.AccessControl, + ac accesscontrol.AccessControl, folderService folder.Service, ) *ImportDashboardService { s := &ImportDashboardService{ pluginDashboardService: pluginDashboardService, dashboardService: dashboardService, libraryPanelService: libraryPanelService, + folderService: folderService, } dashboardImportAPI := api.New(s, quotaService, pluginStore, ac) @@ -39,6 +41,7 @@ type ImportDashboardService struct { pluginDashboardService plugindashboards.Service dashboardService dashboards.DashboardService libraryPanelService librarypanels.Service + folderService folder.Service } func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { @@ -80,6 +83,21 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb generatedDash.Del("__inputs") generatedDash.Del("__requires") + // here we need to get FolderId from FolderUID if it present in the request, if both exist, FolderUID would overwrite FolderID + if req.FolderUid != "" { + folder, err := s.folderService.GetFolderByUID(ctx, req.User, req.User.OrgID, req.FolderUid) + if err != nil { + return nil, err + } + req.FolderId = folder.Id + } else { + folder, err := s.folderService.GetFolderByID(ctx, req.User, req.FolderId, req.User.OrgID) + if err != nil { + return nil, err + } + req.FolderUid = folder.Uid + } + saveCmd := models.SaveDashboardCommand{ Dashboard: generatedDash, OrgId: req.User.OrgID, @@ -118,6 +136,7 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb Path: req.Path, Revision: savedDashboard.Data.Get("revision").MustInt64(1), FolderId: savedDashboard.FolderId, + FolderUID: req.FolderUid, ImportedUri: "db/" + savedDashboard.Slug, ImportedUrl: savedDashboard.GetUrl(), ImportedRevision: savedDashboard.Data.Get("revision").MustInt64(1), diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index b6f6981b1ca..c73f8605a30 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/plugindashboards" @@ -53,10 +54,18 @@ func TestImportDashboardService(t *testing.T) { return nil }, } + folderService := &foldertest.FakeService{ + ExpectedFolder: &models.Folder{ + Id: 5, + Uid: "123", + }, + } + s := &ImportDashboardService{ pluginDashboardService: pluginDashboardService, dashboardService: dashboardService, libraryPanelService: libraryPanelService, + folderService: folderService, } req := &dashboardimport.ImportDashboardRequest{ @@ -105,9 +114,16 @@ func TestImportDashboardService(t *testing.T) { }, } libraryPanelService := &libraryPanelServiceMock{} + folderService := &foldertest.FakeService{ + ExpectedFolder: &models.Folder{ + Id: 5, + Uid: "123", + }, + } s := &ImportDashboardService{ dashboardService: dashboardService, libraryPanelService: libraryPanelService, + folderService: folderService, } loadResp, err := loadTestDashboard(context.Background(), &plugindashboards.LoadPluginDashboardRequest{ From 5fa0936b7e6ee8346f7b821a5076269baac506d9 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Tue, 8 Nov 2022 11:41:57 +0000 Subject: [PATCH 112/926] Alerting: Fix mathexp.NoData in ConditionsCmd (#56812) This commit fixes an issue where mathexp.NoData would return an error in ConditionsCmd (Classic Condition) instead of no data. It further refactors the Execute method to make it easier to understand. --- pkg/expr/classic/classic.go | 144 ++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 4b06f0ce65b..9f4c1100ade 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -69,104 +69,106 @@ func (cmd *ConditionsCmd) NeedsVars() []string { // Execute runs the command and returns the results or an error if the command // failed to execute. func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.Vars) (mathexp.Results, error) { - firing := true - newRes := mathexp.Results{} - noDataFound := true + // isFiring and isNoData tracks whether ConditionsCmd is firing or no data + var isFiring, isNoData bool + var res mathexp.Results - matches := []EvalMatch{} + matches := make([]EvalMatch, 0) + for ix, cond := range cmd.Conditions { + // isCondFiring and isCondNoData tracks whether the condition is firing or no data + // + // There are a number of reasons a condition can have no data: + // + // 1. The input data vars[cond.InputRefID] has no values + // 2. The input data has one or more values, however all are mathexp.NoData + // 3. The input data has one or more values of mathexp.Number or mathexp.Series, + // however the either all mathexp.Number have a nil float64 or the reduce function + // for all mathexp.Series returns a mathexp.Number with a nil float64 + // 4. The input data is a combination of all mathexp.NoData, mathexp.Number with a nil + // float64, or mathexp.Series that reduce to a nil float64 + var isCondFiring, isCondNoData bool + var numSeriesNoData int - for i, c := range cmd.Conditions { - querySeriesSet := vars[c.InputRefID] - nilReducedCount := 0 - firingCount := 0 - for _, val := range querySeriesSet.Values { - var reducedNum mathexp.Number - var name string - switch v := val.(type) { + series := vars[cond.InputRefID] + for _, value := range series.Values { + var ( + name string + number mathexp.Number + ) + switch v := value.(type) { case mathexp.NoData: + // Reduce expressions return v.New(), however classic conditions use the operator + // in the condition to determine if the outcome of ConditionsCmd is no data. // To keep this code as simple as possible we translate mathexp.NoData into a // mathexp.Number with a nil value so number.GetFloat64Value() returns nil - reducedNum = mathexp.NewNumber("no data", nil) - reducedNum.SetValue(nil) - case mathexp.Series: - reducedNum = c.Reducer.Reduce(v) - name = v.GetName() + number = mathexp.NewNumber("no data", nil) + number.SetValue(nil) case mathexp.Number: - reducedNum = v if len(v.Frame.Fields) > 0 { name = v.Frame.Fields[0].Name } + number = v + case mathexp.Series: + name = v.GetName() + number = cond.Reducer.Reduce(v) default: - return newRes, fmt.Errorf("can only reduce type series, got type %v", val.Type()) + return res, fmt.Errorf("can only reduce type series, got type %v", v.Type()) } - // TODO handle error / no data signals - thisCondNoDataFound := reducedNum.GetFloat64Value() == nil - - if thisCondNoDataFound { - nilReducedCount++ - } - - evalRes := c.Evaluator.Eval(reducedNum) - - if evalRes { - match := EvalMatch{ - Value: reducedNum.GetFloat64Value(), + // Check if the value was either a mathexp.NoData, a mathexp.Number with a nil float64, + // or mathexp.Series that reduced to a nil float64 + if number.GetFloat64Value() == nil { + numSeriesNoData += 1 + } else if isValueFiring := cond.Evaluator.Eval(number); isValueFiring { + isCondFiring = true + // If the condition is met then add it to the list of matching conditions + labels := number.GetLabels() + if labels != nil { + labels = labels.Copy() + } + matches = append(matches, EvalMatch{ Metric: name, - } - if reducedNum.GetLabels() != nil { - match.Labels = reducedNum.GetLabels().Copy() - } - matches = append(matches, match) - firingCount++ + Value: number.GetFloat64Value(), + Labels: labels, + }) } } - thisCondFiring := firingCount > 0 - thisCondNoData := len(querySeriesSet.Values) == nilReducedCount - - if i == 0 { - firing = thisCondFiring - noDataFound = thisCondNoData - } - - if c.Operator == "or" { - firing = firing || thisCondFiring - noDataFound = noDataFound || thisCondNoData - } else { - firing = firing && thisCondFiring - noDataFound = noDataFound && thisCondNoData - } - - if thisCondNoData { + // The condition is no data iff all the input data is a combination of all mathexp.NoData, + // mathexp.Number with a nil loat64, or mathexp.Series that reduce to a nil float64 + isCondNoData = numSeriesNoData == len(series.Values) + if isCondNoData { matches = append(matches, EvalMatch{ Metric: "NoData", }) - noDataFound = true } - firingCount = 0 - nilReducedCount = 0 + if ix == 0 { + isFiring = isCondFiring + isNoData = isCondNoData + } else if cond.Operator == "or" { + isFiring = isFiring || isCondFiring + isNoData = isNoData || isCondNoData + } else { + isFiring = isFiring && isCondFiring + isNoData = isNoData && isCondNoData + } } - num := mathexp.NewNumber("", nil) - - num.SetMeta(matches) - var v float64 - switch { - case noDataFound: - num.SetValue(nil) - case firing: + number := mathexp.NewNumber("", nil) + number.SetMeta(matches) + if isFiring { v = 1 - num.SetValue(&v) - case !firing: - num.SetValue(&v) + number.SetValue(&v) + } else if isNoData { + number.SetValue(nil) + } else { + number.SetValue(&v) } - newRes.Values = append(newRes.Values, num) - - return newRes, nil + res.Values = append(res.Values, number) + return res, nil } // EvalMatch represents the series violating the threshold. From 786c7faff2c70a563bd1f3f391e28564d1c59964 Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Tue, 8 Nov 2022 07:08:01 -0500 Subject: [PATCH 113/926] Grafana Enterprise Packaging: Set to conflict with `grafana`, not replace (#58189) * Grafana Enterprise Packaging: Set to conflict with `grafana`, not replace When `grafana` and `grafana-enterprise` are in the same RPM repository, grafana-enterprise takes precedence over Grafana This is not what we want. Users should be able to install either OSS or Enterprise * Set it only one way. It's how it's currently tested --- pkg/build/packaging/grafana.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/build/packaging/grafana.go b/pkg/build/packaging/grafana.go index 3aaf743a5be..6d48dbb8b57 100644 --- a/pkg/build/packaging/grafana.go +++ b/pkg/build/packaging/grafana.go @@ -384,7 +384,7 @@ func executeFPM(options linuxPackageOptions, packageRoot, srcDir string) error { "-a", string(options.packageArch), } if options.edition == config.EditionEnterprise || options.edition == config.EditionEnterprise2 || options.goArch == config.ArchARMv6 { - args = append(args, "--replaces", "grafana") + args = append(args, "--conflicts", "grafana") } if options.edition == config.EditionOSS { args = append(args, "--license", "\"AGPLv3\"") From f1f401147f5ea3bf71099b6358a5ee81f7c68f6a Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Tue, 8 Nov 2022 07:10:56 -0500 Subject: [PATCH 114/926] Linux repositories: Document `apt|rpm.grafana.com` (#57527) * Linux repositories: Document `apt|rpm.grafana.com` We'll be moving off packages.grafana.com (it will be linked to the new repositories) The new package repositories are easier to use, so let's document them instead Question: Are the scripts in `scripts/build/update_repo` and `scripts/verify-repo-update` still used? They seem very old, and seem like the generation of scripts before grabpl was created * Shorter RPM docs. No need to copy the same snippet twice * Add warning about repository migration * oops --- .../installation/debian/index.md | 56 ++++++--------- .../setup-grafana/installation/rpm/index.md | 68 ++++++++----------- 2 files changed, 50 insertions(+), 74 deletions(-) diff --git a/docs/sources/setup-grafana/installation/debian/index.md b/docs/sources/setup-grafana/installation/debian/index.md index 0f4587546c0..d0b5f1a9bd0 100644 --- a/docs/sources/setup-grafana/installation/debian/index.md +++ b/docs/sources/setup-grafana/installation/debian/index.md @@ -12,6 +12,12 @@ weight: 100 This page explains how to install Grafana dependencies, download and install Grafana, get the service up and running on your Debian or Ubuntu system, and also describes the installation package details. +## Repository migration (November 8th 2022) + +From that date, Grafana packages will be served from a new repository ( -> ). The new repository serves, from a single APT configuration, all Grafana OSS products, as well as Grafana Enterprise. + +The old URLs will still work, serving the content from the new repository, but you may encounter warnings about some repository attributes changing (e.g. `Origin` and `Label`). + ## Note on upgrading While the process for upgrading Grafana is very similar to installing Grafana, there are some key backup steps you should perform. Read [Upgrading Grafana]({{< relref "../../upgrade-grafana/" >}}) for tips and guidance on updating an existing installation. @@ -24,67 +30,45 @@ You can install Grafana using our official APT repository, by downloading a `.de If you install from the APT repository, then Grafana is automatically updated every time you run `apt-get update`. -| Grafana Version | Package | Repository | -| ------------------------- | ------------------ | --------------------------------------------------------- | -| Grafana Enterprise | grafana-enterprise | `https://packages.grafana.com/enterprise/deb stable main` | -| Grafana Enterprise (Beta) | grafana-enterprise | `https://packages.grafana.com/enterprise/deb beta main` | -| Grafana OSS | grafana | `https://packages.grafana.com/oss/deb stable main` | -| Grafana OSS (Beta) | grafana | `https://packages.grafana.com/oss/deb beta main` | +| Grafana Version | Package | Repository | +| ------------------------- | ------------------ | ------------------------------------- | +| Grafana Enterprise | grafana-enterprise | `https://apt.grafana.com stable main` | +| Grafana Enterprise (Beta) | grafana-enterprise | `https://apt.grafana.com beta main` | +| Grafana OSS | grafana | `https://apt.grafana.com stable main` | +| Grafana OSS (Beta) | grafana | `https://apt.grafana.com beta main` | > **Note:** Grafana Enterprise is the recommended and default edition. It is available for free and includes all the features of the OSS edition. You can also upgrade to the [full Enterprise feature set](https://grafana.com/products/enterprise/?utm_source=grafana-install-page), which has support for [Enterprise plugins](https://grafana.com/grafana/plugins/?enterprise=1&utcm_source=grafana-install-page). -#### To install the latest Enterprise edition: +#### To install the latest release: ```bash sudo apt-get install -y apt-transport-https sudo apt-get install -y software-properties-common wget -sudo wget -q -O /usr/share/keyrings/grafana.key https://packages.grafana.com/gpg.key +sudo wget -q -O /usr/share/keyrings/grafana.key https://apt.grafana.com/gpg.key ``` Add this repository for stable releases: ```bash -echo "deb [signed-by=/usr/share/keyrings/grafana.key] https://packages.grafana.com/enterprise/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list +echo "deb [signed-by=/usr/share/keyrings/grafana.key] https://apt.grafana.com stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list ``` Add this repository if you want beta releases: ```bash -echo "deb [signed-by=/usr/share/keyrings/grafana.key] https://packages.grafana.com/enterprise/deb beta main" | sudo tee -a /etc/apt/sources.list.d/grafana.list +echo "deb [signed-by=/usr/share/keyrings/grafana.key] https://apt.grafana.com beta main" | sudo tee -a /etc/apt/sources.list.d/grafana.list ``` After you add the repository: ```bash sudo apt-get update -sudo apt-get install grafana-enterprise -``` -#### To install the latest OSS release: - -```bash -sudo apt-get install -y apt-transport-https -sudo apt-get install -y software-properties-common wget -sudo wget -q -O /usr/share/keyrings/grafana.key https://packages.grafana.com/gpg.key -``` - -Add this repository for stable releases: - -```bash -echo "deb [signed-by=/usr/share/keyrings/grafana.key] https://packages.grafana.com/oss/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list -``` - -Add this repository if you want beta releases: - -```bash -echo "deb [signed-by=/usr/share/keyrings/grafana.key] https://packages.grafana.com/oss/deb beta main" | sudo tee -a /etc/apt/sources.list.d/grafana.list -``` - -After you add the repository: - -```bash -sudo apt-get update +# Install the latest OSS release: sudo apt-get install grafana + +# Install the latest Enterprise release: +sudo apt-get install grafana-enterprise ``` ### Install .deb package diff --git a/docs/sources/setup-grafana/installation/rpm/index.md b/docs/sources/setup-grafana/installation/rpm/index.md index 80ba8d29be3..fdcf70e5b6f 100644 --- a/docs/sources/setup-grafana/installation/rpm/index.md +++ b/docs/sources/setup-grafana/installation/rpm/index.md @@ -13,6 +13,12 @@ weight: 400 This topic explains how to install Grafana dependencies, download and install Grafana, get the service up and running on your RPM-based Linux system, and the installation package details. +## Repository migration (November 8th 2022) + +From that date, Grafana packages will be served from a new repository ( -> ). The new repository serves, from a single YUM/DNF configuration, all Grafana OSS products, as well as Grafana Enterprise. + +The old URLs will still work, serving the content from the new repository, but you may encounter warnings about some repository attributes changing. + ## Note on upgrading While the process for upgrading Grafana is very similar to installing Grafana, there are some key backup steps you should perform. Read [Upgrading Grafana]({{< relref "../../upgrade-grafana/" >}}) for tips and guidance on updating an existing installation. @@ -25,12 +31,10 @@ You can install Grafana from a YUM repository, manually using YUM, manually usin If you install from the YUM repository, then Grafana is automatically updated every time you run `sudo yum update`. -| Grafana Version | Package | Repository | -| ------------------------- | ------------------ | -------------------------------------------------- | -| Grafana Enterprise | grafana-enterprise | `https://packages.grafana.com/enterprise/rpm` | -| Grafana Enterprise (Beta) | grafana-enterprise | `https://packages.grafana.com/enterprise/rpm-beta` | -| Grafana OSS | grafana | `https://packages.grafana.com/oss/rpm` | -| Grafana OSS (Beta) | grafana | `https://packages.grafana.com/oss/rpm-beta` | +| Grafana Version | Package | Repository | +| ------------------ | ------------------ | ------------------------- | +| Grafana Enterprise | grafana-enterprise | `https://rpm.grafana.com` | +| Grafana OSS | grafana | `https://rpm.grafana.com` | > **Note:** Grafana Enterprise is the recommended and default edition. It is available for free and includes all the features of the OSS Edition. You can also upgrade to the [full Enterprise feature set](https://grafana.com/products/enterprise/?utm_source=grafana-install-page) and has support for [Enterprise plugins](https://grafana.com/grafana/plugins/?enterprise=1&utcm_source=grafana-install-page). @@ -40,40 +44,28 @@ Add a new file to your YUM repo using the method of your choice. The command bel sudo nano /etc/yum.repos.d/grafana.repo ``` -Choose if you want to install the Open Source or Enterprise edition of Grafana and enter the information from the edition you've chosen into `grafana.repo`. If you want to install the beta version of Grafana you need to replace the URL with a beta URL from the table above. +```bash +[grafana] +name=grafana +baseurl=https://rpm.grafana.com +repo_gpgcheck=1 +enabled=1 +gpgcheck=1 +gpgkey=https://rpm.grafana.com/gpg.key +sslverify=1 +sslcacert=/etc/pki/tls/certs/ca-bundle.crt +``` + +Optionally, add an exclude line to your `.repo` file to prevent beta versions from being installed. + +```bash +exclude=*beta* +``` + +Install Grafana with one of the following commands > We recommend all users to install the Enterprise Edition of Grafana, which can be seamlessly upgraded with a Grafana Enterprise [subscription](https://grafana.com/products/enterprise/?utm_source=grafana-install-page). -For Enterprise releases: - -```bash -[grafana] -name=grafana -baseurl=https://packages.grafana.com/enterprise/rpm -repo_gpgcheck=1 -enabled=1 -gpgcheck=1 -gpgkey=https://packages.grafana.com/gpg.key -sslverify=1 -sslcacert=/etc/pki/tls/certs/ca-bundle.crt -``` - -For OSS releases: - -```bash -[grafana] -name=grafana -baseurl=https://packages.grafana.com/oss/rpm -repo_gpgcheck=1 -enabled=1 -gpgcheck=1 -gpgkey=https://packages.grafana.com/gpg.key -sslverify=1 -sslcacert=/etc/pki/tls/certs/ca-bundle.crt -``` - -Install Grafana with one of the following commands: - ```bash sudo yum install grafana @@ -110,7 +102,7 @@ sudo yum install If you install with RPM, then you will need to manually update Grafana for each new version. This method varies according to which Linux OS you are running. Read the instructions fully before you begin. -**Note:** The .rpm files are signed, you can verify the signature with this [public GPG key](https://packages.grafana.com/gpg.key). +**Note:** The .rpm files are signed, you can verify the signature with this [public GPG key](https://rpm.grafana.com/gpg.key). 1. On the [Grafana download page](https://grafana.com/grafana/download), select the Grafana version you want to install. - The most recent Grafana version is selected by default. From bfd14709c9fad5366364e02601864bbba3d9b077 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Tue, 8 Nov 2022 14:26:31 +0100 Subject: [PATCH 115/926] Levitate: Only run workflows when the NPM packages change (#58206) * chore: only run Levitate when our NPM packages have changes * chore: show the Levitate workflow as passed even if it was skipped --- .../detect-breaking-changes-build-skip.yml | 32 +++++++++++++++ .../detect-breaking-changes-build.yml | 8 +++- .../detect-breaking-changes-report.yml | 39 +++++++++++++------ 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/detect-breaking-changes-build-skip.yml diff --git a/.github/workflows/detect-breaking-changes-build-skip.yml b/.github/workflows/detect-breaking-changes-build-skip.yml new file mode 100644 index 00000000000..5b71987e0fe --- /dev/null +++ b/.github/workflows/detect-breaking-changes-build-skip.yml @@ -0,0 +1,32 @@ +# Workflow for skipping the Levitate detection +# (This is needed because workflows that are skipped due to path filtering will show up as pending in Github. +# As this has the same name as the one in detect-breaking-changes-build.yml it will take over in these cases and succeed quickly.) + +name: Levitate / Detect breaking changes + +on: + pull_request: + paths-ignore: + - "packages/**" + +jobs: + detect: + name: Detect breaking changes + runs-on: ubuntu-latest + + steps: + - name: Skipping + run: echo "No modifications in the public API (packages/), skipping." + + # Build and persist output as a JSON (we need to tell the report workflow that the check has been skipped) + - name: Persisting the check output + run: | + mkdir -p ./levitate + echo "{ \"shouldSkip\": true }" > ./levitate/result.json + + # Upload artifact (so it can be used in the more privileged "report" workflow) + - name: Upload check output as artifact + uses: actions/upload-artifact@v3 + with: + name: levitate + path: levitate/ diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml index b1b2861fdc4..55145015db4 100644 --- a/.github/workflows/detect-breaking-changes-build.yml +++ b/.github/workflows/detect-breaking-changes-build.yml @@ -1,6 +1,12 @@ +# Only runs if anything under the packages/ directory changes. +# (Otherwise detect-breaking-changes-build-skip.yml takes over) + name: Levitate / Detect breaking changes -on: pull_request +on: + pull_request: + paths: + - 'packages/**' jobs: buildPR: diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml index 411395af025..45e2804d077 100644 --- a/.github/workflows/detect-breaking-changes-report.yml +++ b/.github/workflows/detect-breaking-changes-report.yml @@ -10,12 +10,13 @@ jobs: name: Report runs-on: ubuntu-latest env: - ARTIFACT_FOLDER: '${{ github.workspace }}/tmp' - ARTIFACT_NAME: 'levitate' + ARTIFACT_NAME: 'levitate' # The name of the artifact that we would like to download + ARTIFACT_FOLDER: '${{ github.workspace }}/tmp' # The name of the folder where we will download the artifact to steps: - uses: actions/checkout@v3 - + + # Download artifact (as a .zip archive) - name: 'Download artifact' uses: actions/github-script@v6 env: @@ -49,9 +50,12 @@ jobs: fs.mkdirSync(artifactFolder, { recursive: true }); fs.writeFileSync(`${ artifactFolder }/${ artifactName }.zip`, Buffer.from(download.data)); + # Unzip artifact - name: Unzip artifact run: unzip "${ARTIFACT_FOLDER}/${ARTIFACT_NAME}.zip" -d "${ARTIFACT_FOLDER}" + # Parse the artifact and register fields as step output variables + # (All fields in the JSON will be available as ${{ steps.levitate-run.outputs. }} - name: Parsing levitate result uses: actions/github-script@v6 id: levitate-run @@ -61,8 +65,15 @@ jobs: const script = require('./.github/workflows/scripts/json-file-to-job-output.js'); await script({ core, filePath }); + # Skip - print a message if the "Detect" workflow was skipped + - name: Check if the workflow should be skipped + if: steps.levitate-run.outputs.shouldSkip == 'true' + run: echo "Skipping." + + # Check if label exists - name: Check if "levitate breaking change" label exists id: does-label-exist + if: steps.levitate-run.outputs.shouldSkip != 'true' uses: actions/github-script@v6 env: PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} @@ -78,8 +89,9 @@ jobs: return doesExist ? 1 : 0; + # Comment on the PR - name: Comment on PR - if: ${{ steps.levitate-run.outputs.exit_code == 1 }} + if: steps.levitate-run.outputs.exit_code == 1 && steps.levitate-run.outputs.shouldSkip != 'true' uses: marocchino/sticky-pull-request-comment@v2 with: number: ${{ steps.levitate-run.outputs.pr_number }} @@ -93,8 +105,9 @@ jobs: [Console output](${{ steps.levitate-run.outputs.job_link }}) [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide.md) - - name: Remove comment on PR - if: ${{ steps.levitate-run.outputs.exit_code == 0 }} + # Remove comment from the PR (no more breaking changes) + - name: Remove comment from PR + if: steps.levitate-run.outputs.exit_code == 0 && steps.levitate-run.outputs.shouldSkip != 'true' uses: marocchino/sticky-pull-request-comment@v2 with: number: ${{ steps.levitate-run.outputs.pr_number }} @@ -103,7 +116,7 @@ jobs: # Posts a notification to Slack if a PR has a breaking change and it did not have a breaking change before - name: Post to Slack id: slack - if: ${{ steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 }} + if: steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 && steps.levitate-run.outputs.shouldSkip != 'true' uses: slackapi/slack-github-action@v1.23.0 with: payload: | @@ -117,8 +130,9 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_LEVITATE_WEBHOOK_URL }} + # Add the label - name: Add "levitate breaking change" label - if: ${{ steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 }} + if: steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 && steps.levitate-run.outputs.shouldSkip != 'true' uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} @@ -132,8 +146,9 @@ jobs: labels: ['levitate breaking change'] }) + # Remove label (no more breaking changes) - name: Remove "levitate breaking change" label - if: ${{ steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 }} + if: steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 && steps.levitate-run.outputs.shouldSkip != 'true' uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} @@ -147,10 +162,11 @@ jobs: name: 'levitate breaking change' }) + # Add reviewers # This is very weird, the actual request goes through (comes back with a 201), but does not assign the team. # Related issue: https://github.com/renovatebot/renovate/issues/1908 - name: Add "grafana/plugins-platform-frontend" as a reviewer - if: ${{ steps.levitate-run.outputs.exit_code == 1 }} + if: steps.levitate-run.outputs.exit_code && steps.levitate-run.outputs.shouldSkip != 'true' uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} @@ -165,8 +181,9 @@ jobs: team_reviewers: ['grafana/plugins-platform-frontend'] }); + # Remove reviewers (no more breaking changes) - name: Remove "grafana/plugins-platform-frontend" from the list of reviewers - if: ${{ steps.levitate-run.outputs.exit_code == 0 }} + if: steps.levitate-run.outputs.exit_code == 0 && steps.levitate-run.outputs.shouldSkip != 'true' uses: actions/github-script@v6 env: PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }} From aa69a8463f2b107aac3075392b840b5bfebc0150 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Tue, 8 Nov 2022 13:35:58 +0000 Subject: [PATCH 116/926] Revert "Alerting: Fix mathexp.NoData in ConditionsCmd (#56812)" (#58423) This reverts commit 5fa0936b7e6ee8346f7b821a5076269baac506d9. --- pkg/expr/classic/classic.go | 144 ++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 73 deletions(-) diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 9f4c1100ade..4b06f0ce65b 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -69,106 +69,104 @@ func (cmd *ConditionsCmd) NeedsVars() []string { // Execute runs the command and returns the results or an error if the command // failed to execute. func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.Vars) (mathexp.Results, error) { - // isFiring and isNoData tracks whether ConditionsCmd is firing or no data - var isFiring, isNoData bool - var res mathexp.Results + firing := true + newRes := mathexp.Results{} + noDataFound := true - matches := make([]EvalMatch, 0) - for ix, cond := range cmd.Conditions { - // isCondFiring and isCondNoData tracks whether the condition is firing or no data - // - // There are a number of reasons a condition can have no data: - // - // 1. The input data vars[cond.InputRefID] has no values - // 2. The input data has one or more values, however all are mathexp.NoData - // 3. The input data has one or more values of mathexp.Number or mathexp.Series, - // however the either all mathexp.Number have a nil float64 or the reduce function - // for all mathexp.Series returns a mathexp.Number with a nil float64 - // 4. The input data is a combination of all mathexp.NoData, mathexp.Number with a nil - // float64, or mathexp.Series that reduce to a nil float64 - var isCondFiring, isCondNoData bool - var numSeriesNoData int + matches := []EvalMatch{} - series := vars[cond.InputRefID] - for _, value := range series.Values { - var ( - name string - number mathexp.Number - ) - switch v := value.(type) { + for i, c := range cmd.Conditions { + querySeriesSet := vars[c.InputRefID] + nilReducedCount := 0 + firingCount := 0 + for _, val := range querySeriesSet.Values { + var reducedNum mathexp.Number + var name string + switch v := val.(type) { case mathexp.NoData: - // Reduce expressions return v.New(), however classic conditions use the operator - // in the condition to determine if the outcome of ConditionsCmd is no data. // To keep this code as simple as possible we translate mathexp.NoData into a // mathexp.Number with a nil value so number.GetFloat64Value() returns nil - number = mathexp.NewNumber("no data", nil) - number.SetValue(nil) + reducedNum = mathexp.NewNumber("no data", nil) + reducedNum.SetValue(nil) + case mathexp.Series: + reducedNum = c.Reducer.Reduce(v) + name = v.GetName() case mathexp.Number: + reducedNum = v if len(v.Frame.Fields) > 0 { name = v.Frame.Fields[0].Name } - number = v - case mathexp.Series: - name = v.GetName() - number = cond.Reducer.Reduce(v) default: - return res, fmt.Errorf("can only reduce type series, got type %v", v.Type()) + return newRes, fmt.Errorf("can only reduce type series, got type %v", val.Type()) } - // Check if the value was either a mathexp.NoData, a mathexp.Number with a nil float64, - // or mathexp.Series that reduced to a nil float64 - if number.GetFloat64Value() == nil { - numSeriesNoData += 1 - } else if isValueFiring := cond.Evaluator.Eval(number); isValueFiring { - isCondFiring = true - // If the condition is met then add it to the list of matching conditions - labels := number.GetLabels() - if labels != nil { - labels = labels.Copy() - } - matches = append(matches, EvalMatch{ + // TODO handle error / no data signals + thisCondNoDataFound := reducedNum.GetFloat64Value() == nil + + if thisCondNoDataFound { + nilReducedCount++ + } + + evalRes := c.Evaluator.Eval(reducedNum) + + if evalRes { + match := EvalMatch{ + Value: reducedNum.GetFloat64Value(), Metric: name, - Value: number.GetFloat64Value(), - Labels: labels, - }) + } + if reducedNum.GetLabels() != nil { + match.Labels = reducedNum.GetLabels().Copy() + } + matches = append(matches, match) + firingCount++ } } - // The condition is no data iff all the input data is a combination of all mathexp.NoData, - // mathexp.Number with a nil loat64, or mathexp.Series that reduce to a nil float64 - isCondNoData = numSeriesNoData == len(series.Values) - if isCondNoData { + thisCondFiring := firingCount > 0 + thisCondNoData := len(querySeriesSet.Values) == nilReducedCount + + if i == 0 { + firing = thisCondFiring + noDataFound = thisCondNoData + } + + if c.Operator == "or" { + firing = firing || thisCondFiring + noDataFound = noDataFound || thisCondNoData + } else { + firing = firing && thisCondFiring + noDataFound = noDataFound && thisCondNoData + } + + if thisCondNoData { matches = append(matches, EvalMatch{ Metric: "NoData", }) + noDataFound = true } - if ix == 0 { - isFiring = isCondFiring - isNoData = isCondNoData - } else if cond.Operator == "or" { - isFiring = isFiring || isCondFiring - isNoData = isNoData || isCondNoData - } else { - isFiring = isFiring && isCondFiring - isNoData = isNoData && isCondNoData - } + firingCount = 0 + nilReducedCount = 0 } + num := mathexp.NewNumber("", nil) + + num.SetMeta(matches) + var v float64 - number := mathexp.NewNumber("", nil) - number.SetMeta(matches) - if isFiring { + switch { + case noDataFound: + num.SetValue(nil) + case firing: v = 1 - number.SetValue(&v) - } else if isNoData { - number.SetValue(nil) - } else { - number.SetValue(&v) + num.SetValue(&v) + case !firing: + num.SetValue(&v) } - res.Values = append(res.Values, number) - return res, nil + newRes.Values = append(newRes.Values, num) + + return newRes, nil } // EvalMatch represents the series violating the threshold. From c1d677c1743ee3d3fccd04d1f2081f7cd4eb9eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Tue, 8 Nov 2022 14:55:50 +0100 Subject: [PATCH 117/926] Internationalization: Translate TimeRangeContent component (#58343) --- .../TimeRangePicker/TimeRangeContent.tsx | 24 ++++++++++++------- public/locales/de-DE/grafana.json | 8 +++++++ public/locales/en-US/grafana.json | 8 +++++++ public/locales/es-ES/grafana.json | 8 +++++++ public/locales/fr-FR/grafana.json | 8 +++++++ public/locales/pseudo-LOCALE/grafana.json | 8 +++++++ public/locales/zh-Hans/grafana.json | 8 +++++++ 7 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 4018c7e49c6..c9cf01ce6a7 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -17,6 +17,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Icon, Tooltip } from '../..'; import { useStyles2 } from '../../..'; +import { t, Trans } from '../../../utils/i18n'; import { Button } from '../../Button'; import { Field } from '../../Forms/Field'; import { Input } from '../../Input/Input'; @@ -40,8 +41,8 @@ interface InputState { } const ERROR_MESSAGES = { - default: 'Please enter a past date or "now"', - range: '"From" can\'t be after "To"', + default: () => t('time-picker.range-content.default-error', 'Please enter a past date or "now"'), + range: () => t('time-picker.range-content.range-error', '"From" can\'t be after "To"'), }; export const TimeRangeContent = (props: Props) => { @@ -95,11 +96,14 @@ export const TimeRangeContent = (props: Props) => { }; const fiscalYear = rangeUtil.convertRawToRange({ from: 'now/fy', to: 'now/fy' }, timeZone, fiscalYearStartMonth); + const fiscalYearMessage = t('time-picker.range-content.fiscal-year', 'Fiscal year'); const fyTooltip = (
{rangeUtil.isFiscal(value) ? ( - + ) : null} @@ -119,7 +123,11 @@ export const TimeRangeContent = (props: Props) => { return (
- + event.stopPropagation()} onChange={(event) => onChange(event.currentTarget.value, to.value)} @@ -132,7 +140,7 @@ export const TimeRangeContent = (props: Props) => { {fyTooltip}
- + event.stopPropagation()} onChange={(event) => onChange(from.value, event.currentTarget.value)} @@ -145,7 +153,7 @@ export const TimeRangeContent = (props: Props) => { {fyTooltip}
Date: Tue, 8 Nov 2022 14:58:19 +0100 Subject: [PATCH 118/926] Changelog: Updated changelog for 9.2.4 (#58429) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 913378b1b45..8f058c9bdda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ + + +# 9.2.4 (2022-11-07) + +### Features and enhancements + +- **Access Control:** Add an endpoint for setting several managed resource permissions. [#57893](https://github.com/grafana/grafana/pull/57893), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Accessibility:** Increase `Select` placeholder contrast to be WCAG AA compliant. [#58034](https://github.com/grafana/grafana/pull/58034), [@ashharrison90](https://github.com/ashharrison90) +- **Alerting:** Append org ID to alert notification URLs. [#57123](https://github.com/grafana/grafana/pull/57123), [@neel1996](https://github.com/neel1996) +- **Alerting:** Make the Grouped view the default one for Rules. [#58271](https://github.com/grafana/grafana/pull/58271), [@VikaCep](https://github.com/VikaCep) +- **Build:** Remove unnecessary alpine package updates. [#58005](https://github.com/grafana/grafana/pull/58005), [@DanCech](https://github.com/DanCech) +- **Chore:** Upgrade Go to 1.19.3. [#58052](https://github.com/grafana/grafana/pull/58052), [@sakjur](https://github.com/sakjur) +- **Google Cloud Monitoring:** Set frame interval to draw null values. [#57768](https://github.com/grafana/grafana/pull/57768), [@andresmgot](https://github.com/andresmgot) +- **Instrumentation:** Expose when the binary was built as a gauge. [#57951](https://github.com/grafana/grafana/pull/57951), [@bergquist](https://github.com/bergquist) +- **Loki:** Preserve `X-ID-Token` header. [#57878](https://github.com/grafana/grafana/pull/57878), [@siiimooon](https://github.com/siiimooon) +- **Search:** Reduce requests in folder view. [#55876](https://github.com/grafana/grafana/pull/55876), [@mvsousa](https://github.com/mvsousa) +- **TimeSeries:** More thorough detection of negative values for auto-stacking direction. [#57863](https://github.com/grafana/grafana/pull/57863), [@leeoniya](https://github.com/leeoniya) + +### Bug fixes + +- **Alerting:** Attempt to preserve UID from migrated legacy channel. [#57639](https://github.com/grafana/grafana/pull/57639), [@alexweav](https://github.com/alexweav) +- **Alerting:** Fix response is not returned for invalid Duration in Provisioning API. [#58046](https://github.com/grafana/grafana/pull/58046), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Fix screenshot is not taken for stale series. [#57982](https://github.com/grafana/grafana/pull/57982), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Auth:** Fix admins not seeing pending invites. [#58217](https://github.com/grafana/grafana/pull/58217), [@joshhunt](https://github.com/joshhunt) +- **MSSQL/Postgres:** Fix visual query editor filter disappearing. [#58248](https://github.com/grafana/grafana/pull/58248), [@zoltanbedi](https://github.com/zoltanbedi) +- **Tempo:** Fix dropdown issue on tag field focus. [#57616](https://github.com/grafana/grafana/pull/57616), [@xiyu95](https://github.com/xiyu95) +- **Timeseries:** Fix null pointer when matching fill below to field. [#58030](https://github.com/grafana/grafana/pull/58030), [@mdvictor](https://github.com/mdvictor) + +### Plugin development fixes & changes + +- **Toolkit:** Fix Webpack less-loader config. [#57950](https://github.com/grafana/grafana/pull/57950), [@dessen-xu](https://github.com/dessen-xu) + + # 9.2.3 (2022-10-31) From a255c32e1abfca53dd2586f481bd3ae3c770b334 Mon Sep 17 00:00:00 2001 From: Kristin Laemmert Date: Tue, 8 Nov 2022 08:59:55 -0500 Subject: [PATCH 119/926] nested folders: support creation of nested folders in folder service when feature flag is set (#58364) * nested folders: support creation of nested folders in folder service when feature flag is set --- pkg/services/folder/folderimpl/folder.go | 50 +++++- pkg/services/folder/folderimpl/folder_test.go | 142 +++++++++++++++--- pkg/services/folder/folderimpl/sqlstore.go | 4 +- .../folder/folderimpl/sqlstore_test.go | 17 ++- pkg/services/folder/folderimpl/store_fake.go | 3 + pkg/services/folder/model.go | 1 + .../libraryelements/libraryelements_test.go | 4 +- .../librarypanels/librarypanels_test.go | 4 +- pkg/services/ngalert/tests/util.go | 2 +- 9 files changed, 184 insertions(+), 43 deletions(-) diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 019164504a4..f779dc7dba4 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -7,12 +7,14 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search" @@ -29,7 +31,7 @@ type Service struct { dashboardService dashboards.DashboardService dashboardStore dashboards.Store searchService *search.SearchService - features featuremgmt.FeatureToggles + features *featuremgmt.FeatureManager permissions accesscontrol.FolderPermissionsService // bus is currently used to publish events that cause scheduler to update rules. @@ -42,17 +44,20 @@ func ProvideService( cfg *setting.Cfg, dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, - features featuremgmt.FeatureToggles, + db db.DB, // DB for the (new) nested folder store + features *featuremgmt.FeatureManager, folderPermissionsService accesscontrol.FolderPermissionsService, searchService *search.SearchService, ) folder.Service { ac.RegisterScopeAttributeResolver(dashboards.NewFolderNameScopeResolver(dashboardStore)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(dashboardStore)) + store := ProvideStore(db, cfg, features) return &Service{ cfg: cfg, log: log.New("folder-service"), dashboardService: dashboardService, dashboardStore: dashboardStore, + store: store, searchService: searchService, features: features, permissions: folderPermissionsService, @@ -178,8 +183,8 @@ func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, org return nil, toFolderError(err) } - var folder *models.Folder - folder, err = s.dashboardStore.GetFolderByID(ctx, orgID, dash.Id) + var createdFolder *models.Folder + createdFolder, err = s.dashboardStore.GetFolderByID(ctx, orgID, dash.Id) if err != nil { return nil, err } @@ -198,16 +203,45 @@ func (s *Service) CreateFolder(ctx context.Context, user *user.SignedInUser, org {BuiltinRole: string(org.RoleViewer), Permission: models.PERMISSION_VIEW.String()}, }...) - _, permissionErr = s.permissions.SetPermissions(ctx, orgID, folder.Uid, permissions...) + _, permissionErr = s.permissions.SetPermissions(ctx, orgID, createdFolder.Uid, permissions...) } else if s.cfg.EditorsCanAdmin && user.IsRealUser() && !user.IsAnonymous { - permissionErr = s.MakeUserAdmin(ctx, orgID, userID, folder.Id, true) + permissionErr = s.MakeUserAdmin(ctx, orgID, userID, createdFolder.Id, true) } if permissionErr != nil { - s.log.Error("Could not make user admin", "folder", folder.Title, "user", userID, "error", permissionErr) + s.log.Error("Could not make user admin", "folder", createdFolder.Title, "user", userID, "error", permissionErr) } - return folder, nil + if s.features.IsEnabled(featuremgmt.FlagNestedFolders) { + var description string + if dash.Data != nil { + description = dash.Data.Get("description").MustString() + } + + _, err := s.store.Create(ctx, folder.CreateFolderCommand{ + // TODO: Today, if a UID isn't specified, the dashboard store + // generates a new UID. The new folder store will need to do this as + // well, but for now we take the UID from the newly created folder. + UID: dash.Uid, + OrgID: orgID, + Title: title, + Description: description, + ParentUID: folder.RootFolderUID, + }) + if err != nil { + // We'll log the error and also roll back the previously-created + // (legacy) folder. + s.log.Error("error saving folder to nested folder store", err) + _, err = s.DeleteFolder(ctx, user, orgID, createdFolder.Uid, true) + if err != nil { + s.log.Error("error deleting folder after failed save to nested folder store", err) + } + return createdFolder, err + } + // The folder UID is specified (or generated) during creation, so we'll + // stop here and return the created model.Folder. + } + return createdFolder, nil } func (s *Service) UpdateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 07a36ca9a7a..f1b3d01db34 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -2,6 +2,7 @@ package folderimpl import ( "context" + "errors" "math/rand" "testing" @@ -10,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" @@ -19,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -34,7 +37,7 @@ func TestIntegrationProvideFolderService(t *testing.T) { t.Run("should register scope resolvers", func(t *testing.T) { cfg := setting.NewCfg() ac := acmock.New() - ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, nil, nil, nil, nil, nil) + ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, nil, nil, nil, &featuremgmt.FeatureManager{}, nil, nil) require.Len(t, ac.Calls.RegisterAttributeScopeResolver, 2) }) @@ -45,20 +48,24 @@ func TestIntegrationFolderService(t *testing.T) { t.Skip("skipping integration test") } t.Run("Folder service tests", func(t *testing.T) { - store := &dashboards.FakeDashboardStore{} + dashStore := &dashboards.FakeDashboardStore{} + db := sqlstore.InitTestDB(t) + store := ProvideStore(db, db.Cfg, featuremgmt.WithFeatures([]interface{}{"nestedFolders"})) + cfg := setting.NewCfg() cfg.RBACEnabled = false features := featuremgmt.WithFeatures() cfg.IsFeatureToggleEnabled = features.IsEnabled folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - dashboardService := dashboardsvc.ProvideDashboardService(cfg, store, nil, features, folderPermissions, dashboardPermissions, acmock.New()) + dashboardService := dashboardsvc.ProvideDashboardService(cfg, dashStore, nil, features, folderPermissions, dashboardPermissions, acmock.New()) service := &Service{ cfg: cfg, log: log.New("test-folder-service"), dashboardService: dashboardService, - dashboardStore: store, + dashboardStore: dashStore, + store: store, searchService: nil, features: features, permissions: folderPermissions, @@ -76,8 +83,8 @@ func TestIntegrationFolderService(t *testing.T) { folder.Id = folderId folder.Uid = folderUID - store.On("GetFolderByID", mock.Anything, orgID, folderId).Return(folder, nil) - store.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(folder, nil) + dashStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(folder, nil) + dashStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(folder, nil) t.Run("When get folder by id should return access denied error", func(t *testing.T) { _, err := service.GetFolderByID(context.Background(), usr, folderId, orgID) @@ -96,13 +103,13 @@ func TestIntegrationFolderService(t *testing.T) { }) t.Run("When creating folder should return access denied error", func(t *testing.T) { - store.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil).Times(2) + dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil).Times(2) _, err := service.CreateFolder(context.Background(), usr, orgID, folder.Title, folderUID) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) t.Run("When updating folder should return access denied error", func(t *testing.T) { - store.On("GetDashboard", mock.Anything, mock.AnythingOfType("*models.GetDashboardQuery")).Run(func(args mock.Arguments) { + dashStore.On("GetDashboard", mock.Anything, mock.AnythingOfType("*models.GetDashboardQuery")).Run(func(args mock.Arguments) { folder := args.Get(1).(*models.GetDashboardQuery) folder.Result = models.NewDashboard("dashboard-test") folder.Result.IsFolder = true @@ -134,11 +141,11 @@ func TestIntegrationFolderService(t *testing.T) { dash.Id = rand.Int63() f := models.DashboardToFolder(dash) - store.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) - store.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dash, nil).Once() - store.On("GetFolderByID", mock.Anything, orgID, dash.Id).Return(f, nil) + dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dash, nil).Once() + dashStore.On("GetFolderByID", mock.Anything, orgID, dash.Id).Return(f, nil) - actualFolder, err := service.CreateFolder(context.Background(), usr, orgID, dash.Title, "") + actualFolder, err := service.CreateFolder(context.Background(), usr, orgID, dash.Title, "someuid") require.NoError(t, err) require.Equal(t, f, actualFolder) }) @@ -157,9 +164,9 @@ func TestIntegrationFolderService(t *testing.T) { dashboardFolder.Uid = util.GenerateShortUID() f := models.DashboardToFolder(dashboardFolder) - store.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) - store.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dashboardFolder, nil) - store.On("GetFolderByID", mock.Anything, orgID, dashboardFolder.Id).Return(f, nil) + dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dashboardFolder, nil) + dashStore.On("GetFolderByID", mock.Anything, orgID, dashboardFolder.Id).Return(f, nil) req := &models.UpdateFolderCommand{ Uid: dashboardFolder.Uid, @@ -175,10 +182,10 @@ func TestIntegrationFolderService(t *testing.T) { f := models.NewFolder(util.GenerateShortUID()) f.Id = rand.Int63() f.Uid = util.GenerateShortUID() - store.On("GetFolderByUID", mock.Anything, orgID, f.Uid).Return(f, nil) + dashStore.On("GetFolderByUID", mock.Anything, orgID, f.Uid).Return(f, nil) var actualCmd *models.DeleteDashboardCommand - store.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { actualCmd = args.Get(1).(*models.DeleteDashboardCommand) }).Return(nil).Once() @@ -204,7 +211,7 @@ func TestIntegrationFolderService(t *testing.T) { expected := models.NewFolder(util.GenerateShortUID()) expected.Id = rand.Int63() - store.On("GetFolderByID", mock.Anything, orgID, expected.Id).Return(expected, nil) + dashStore.On("GetFolderByID", mock.Anything, orgID, expected.Id).Return(expected, nil) actual, err := service.GetFolderByID(context.Background(), usr, expected.Id, orgID) require.Equal(t, expected, actual) @@ -215,7 +222,7 @@ func TestIntegrationFolderService(t *testing.T) { expected := models.NewFolder(util.GenerateShortUID()) expected.Uid = util.GenerateShortUID() - store.On("GetFolderByUID", mock.Anything, orgID, expected.Uid).Return(expected, nil) + dashStore.On("GetFolderByUID", mock.Anything, orgID, expected.Uid).Return(expected, nil) actual, err := service.GetFolderByUID(context.Background(), usr, orgID, expected.Uid) require.Equal(t, expected, actual) @@ -225,7 +232,7 @@ func TestIntegrationFolderService(t *testing.T) { t.Run("When get folder by title should return folder", func(t *testing.T) { expected := models.NewFolder("TEST-" + util.GenerateShortUID()) - store.On("GetFolderByTitle", mock.Anything, orgID, expected.Title).Return(expected, nil) + dashStore.On("GetFolderByTitle", mock.Anything, orgID, expected.Title).Return(expected, nil) actual, err := service.GetFolderByTitle(context.Background(), usr, orgID, expected.Title) require.Equal(t, expected, actual) @@ -326,3 +333,98 @@ func TestFolderService(t *testing.T) { require.NoError(t, err) }) } + +func TestCreate_NestedFolders(t *testing.T) { + t.Run("with feature flag unset", func(t *testing.T) { + ctx := appcontext.WithUser(context.Background(), usr) + store := &FakeStore{} + dashStore := dashboards.FakeDashboardStore{} + dashboardsvc := dashboards.FakeDashboardService{} + // nothing enabled yet + cfg := setting.NewCfg() + cfg.RBACEnabled = false + features := featuremgmt.WithFeatures() + cfg.IsFeatureToggleEnabled = features.IsEnabled + foldersvc := &Service{ + cfg: cfg, + log: log.New("test-folder-service"), + dashboardService: &dashboardsvc, + dashboardStore: &dashStore, + store: store, + features: features, + } + + // dashboard store & service commands that should be called. + dashboardsvc.On("BuildSaveDashboardCommand", + mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), + mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + + _, err := foldersvc.CreateFolder(ctx, usr, orgID, "myFolder", "myFolder") + require.NoError(t, err) + // CreateFolder should not call the folder store create if the feature toggle is not enabled. + require.False(t, store.CreateCalled) + }) + + t.Run("with nested folder feature flag on", func(t *testing.T) { + ctx := appcontext.WithUser(context.Background(), usr) + store := &FakeStore{} + dashStore := &dashboards.FakeDashboardStore{} + dashboardsvc := &dashboards.FakeDashboardService{} + // nothing enabled yet + cfg := setting.NewCfg() + cfg.RBACEnabled = false + features := featuremgmt.WithFeatures("nestedFolders") + cfg.IsFeatureToggleEnabled = features.IsEnabled + foldersvc := &Service{ + cfg: cfg, + log: log.New("test-folder-service"), + dashboardService: dashboardsvc, + dashboardStore: dashStore, + store: store, + features: features, + } + + t.Run("create, no error", func(t *testing.T) { + // dashboard store & service commands that should be called. + dashboardsvc.On("BuildSaveDashboardCommand", + mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), + mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + _, err := foldersvc.CreateFolder(ctx, usr, orgID, "myFolder", "myFolder") + require.NoError(t, err) + // CreateFolder should also call the folder store's create method. + require.True(t, store.CreateCalled) + }) + + t.Run("create returns error from nested folder service", func(t *testing.T) { + // This test creates and deletes the dashboard, so needs some extra setup. + g := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) + + // dashboard store & service commands that should be called. + dashboardsvc.On("BuildSaveDashboardCommand", + mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), + mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) + dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&models.Folder{}, nil) + + // return an error from the folder store + store.ExpectedError = errors.New("FAILED") + + // the service return success as long as the legacy create succeeds + _, err := foldersvc.CreateFolder(ctx, usr, orgID, "myFolder", "myFolder") + require.Error(t, err, "FAILED") + + // CreateFolder should also call the folder store's create method. + require.True(t, store.CreateCalled) + + t.Cleanup(func() { + guardian.New = g + }) + }) + }) +} diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 5840bbba91c..4c82a7a2dd9 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -18,13 +18,13 @@ type sqlStore struct { db db.DB log log.Logger cfg *setting.Cfg - fm featuremgmt.FeatureManager + fm featuremgmt.FeatureToggles } // sqlStore implements the store interface. var _ store = (*sqlStore)(nil) -func ProvideStore(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureManager) *sqlStore { +func ProvideStore(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) *sqlStore { return &sqlStore{db: db, log: log.New("folder-store"), cfg: cfg, fm: features} } diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 85c1dfaf04e..63f9587d122 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -6,6 +6,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -13,8 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestIntegrationCreate(t *testing.T) { @@ -24,7 +25,7 @@ func TestIntegrationCreate(t *testing.T) { t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) orgID := CreateOrg(t, db) @@ -162,7 +163,7 @@ func TestIntegrationDelete(t *testing.T) { t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) orgID := CreateOrg(t, db) @@ -210,7 +211,7 @@ func TestIntegrationUpdate(t *testing.T) { t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) orgID := CreateOrg(t, db) @@ -315,7 +316,7 @@ func TestIntegrationGet(t *testing.T) { t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) orgID := CreateOrg(t, db) @@ -396,7 +397,7 @@ func TestIntegrationGetParents(t *testing.T) { t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) orgID := CreateOrg(t, db) @@ -460,7 +461,7 @@ func TestIntegrationGetChildren(t *testing.T) { t.Skip("skipping until folder migration is merged") db := sqlstore.InitTestDB(t) - folderStore := ProvideStore(db, db.Cfg, *featuremgmt.WithFeatures()) + folderStore := ProvideStore(db, db.Cfg, &featuremgmt.FeatureManager{}) orgID := CreateOrg(t, db) diff --git a/pkg/services/folder/folderimpl/store_fake.go b/pkg/services/folder/folderimpl/store_fake.go index 302acda3e24..d18351437a6 100644 --- a/pkg/services/folder/folderimpl/store_fake.go +++ b/pkg/services/folder/folderimpl/store_fake.go @@ -10,6 +10,8 @@ type FakeStore struct { ExpectedFolders []*folder.Folder ExpectedFolder *folder.Folder ExpectedError error + + CreateCalled bool } func NewFakeStore() *FakeStore { @@ -19,6 +21,7 @@ func NewFakeStore() *FakeStore { var _ store = (*FakeStore)(nil) func (f *FakeStore) Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) { + f.CreateCalled = true return f.ExpectedFolder, f.ExpectedError } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 3886b8471f4..ee5392db273 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -13,6 +13,7 @@ var ErrInternal = errutil.NewBase(errutil.StatusInternal, "folder.internal") const ( GeneralFolderUID = "general" + RootFolderUID = "" MaxNestedFolderDepth = 8 ) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 3b6a3975ed5..d1f7f307b24 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -310,7 +310,7 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.S cfg, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac, ) - s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, features, folderPermissions, nil) + s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, nil, features, folderPermissions, nil) t.Logf("Creating folder with title and UID %q", title) folder, err := s.CreateFolder(context.Background(), &user, user.OrgID, title, title) require.NoError(t, err) @@ -420,7 +420,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo service := LibraryElementService{ Cfg: sqlStore.Cfg, SQLStore: sqlStore, - folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardService, dashboardStore, features, folderPermissions, nil), + folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardService, dashboardStore, nil, features, folderPermissions, nil), } usr := user.SignedInUser{ diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index a422dc729a2..02b18692e85 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -717,7 +717,7 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user. dashboardPermissions := acmock.NewMockedPermissionsService() dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) d := dashboardservice.ProvideDashboardService(cfg, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac) - s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, features, folderPermissions, nil) + s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, nil, features, folderPermissions, nil) t.Logf("Creating folder with title and UID %q", title) folder, err := s.CreateFolder(context.Background(), user, user.OrgID, title, title) @@ -819,7 +819,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo cfg, dashboardStore, &alerting.DashAlertExtractorService{}, features, folderPermissions, dashboardPermissions, ac, ) - folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardService, dashboardStore, features, folderPermissions, nil) + folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardService, dashboardStore, nil, features, folderPermissions, nil) elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService) service := LibraryPanelService{ diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 6862ed83361..3daf391381b 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -89,7 +89,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, ) bus := bus.ProvideBus(tracing.InitializeTracerForTest()) - folderService := folderimpl.ProvideService(ac, bus, cfg, dashboardService, dashboardStore, features, folderPermissions, nil) + folderService := folderimpl.ProvideService(ac, bus, cfg, dashboardService, dashboardStore, nil, features, folderPermissions, nil) ng, err := ngalert.ProvideService( cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil, From f07da85d8b64c7df00121b6b9ace36f9eca1b9b2 Mon Sep 17 00:00:00 2001 From: Joe Blubaugh Date: Tue, 8 Nov 2022 22:11:02 +0800 Subject: [PATCH 120/926] Dashboards: Provide better error messages in SaveDashboardAsForm (#57866) The existing code uses `instanceof Error` to check for a `message` field on the thrown object. The objects that are thrown are never instances of the error interface. This change introduces a new type that extends Error so that the check works properly and displays a meaningful error message in the UI. --- .../services/ValidationSrv.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/public/app/features/manage-dashboards/services/ValidationSrv.ts b/public/app/features/manage-dashboards/services/ValidationSrv.ts index 0e4c570d4f6..4b300291638 100644 --- a/public/app/features/manage-dashboards/services/ValidationSrv.ts +++ b/public/app/features/manage-dashboards/services/ValidationSrv.ts @@ -5,6 +5,15 @@ const hitTypes = { DASHBOARD: 'dash-db', }; +class ValidationError extends Error { + type: string; + + constructor(type: string, message: string) { + super(message); + this.type = type; + } +} + export class ValidationSrv { rootName = 'general'; @@ -21,17 +30,11 @@ export class ValidationSrv { const nameLowerCased = name.toLowerCase(); if (name.length === 0) { - throw { - type: 'REQUIRED', - message: 'Name is required', - }; + throw new ValidationError('REQUIRED', 'Name is required'); } if (folderId === 0 && nameLowerCased === this.rootName) { - throw { - type: 'EXISTING', - message: 'This is a reserved name and cannot be used for a folder.', - }; + throw new ValidationError('EXISTING', 'This is a reserved name and cannot be used for a folder.'); } const promises = []; @@ -51,10 +54,7 @@ export class ValidationSrv { for (const hit of hits) { if (nameLowerCased === hit.title.toLowerCase()) { - throw { - type: 'EXISTING', - message: existingErrorMessage, - }; + throw new ValidationError('EXISTING', existingErrorMessage); } } From 831ecb467cfaad7942cbe785f732bd91a43b479a Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 8 Nov 2022 14:20:05 +0000 Subject: [PATCH 121/926] Add new PageInfo component (#58421) --- public/app/core/components/Page/types.ts | 5 ++ .../components/PageInfo/PageInfo.test.tsx | 62 +++++++++++++++++++ .../app/core/components/PageInfo/PageInfo.tsx | 52 ++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 public/app/core/components/PageInfo/PageInfo.test.tsx create mode 100644 public/app/core/components/PageInfo/PageInfo.tsx diff --git a/public/app/core/components/Page/types.ts b/public/app/core/components/Page/types.ts index 759a4380155..0e23c966835 100644 --- a/public/app/core/components/Page/types.ts +++ b/public/app/core/components/Page/types.ts @@ -22,6 +22,11 @@ export interface PageProps extends HTMLAttributes { scrollTop?: number; } +export interface PageInfoItem { + label: string; + value: React.ReactNode; +} + export interface PageType extends FC { Header: typeof PageHeader; OldNavOnly: typeof OldNavOnly; diff --git a/public/app/core/components/PageInfo/PageInfo.test.tsx b/public/app/core/components/PageInfo/PageInfo.test.tsx new file mode 100644 index 00000000000..73aa06c8b3b --- /dev/null +++ b/public/app/core/components/PageInfo/PageInfo.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { PageInfoItem } from '../Page/types'; + +import { PageInfo } from './PageInfo'; + +describe('PageInfo', () => { + it('renders the label and value for each info item', () => { + const info: PageInfoItem[] = [ + { + label: 'label1', + value: 'value1', + }, + { + label: 'label2', + value: 2, + }, + ]; + render(); + + // Check labels are visible + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('label2')).toBeInTheDocument(); + + // Check values are visible + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('can render a custom element as a value', () => { + const info: PageInfoItem[] = [ + { + label: 'label1', + value:
value1
, + }, + ]; + render(); + + expect(screen.getByTestId('custom-value')).toBeInTheDocument(); + }); + + it('renders separators between the info items', () => { + const info: PageInfoItem[] = [ + { + label: 'label1', + value: 'value1', + }, + { + label: 'label2', + value: 'value2', + }, + { + label: 'label3', + value: 'value3', + }, + ]; + render(); + + expect(screen.getAllByTestId('page-info-separator')).toHaveLength(info.length - 1); + }); +}); diff --git a/public/app/core/components/PageInfo/PageInfo.tsx b/public/app/core/components/PageInfo/PageInfo.tsx new file mode 100644 index 00000000000..388853ca375 --- /dev/null +++ b/public/app/core/components/PageInfo/PageInfo.tsx @@ -0,0 +1,52 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { PageInfoItem } from '../Page/types'; + +export interface Props { + info: PageInfoItem[]; +} + +export function PageInfo({ info }: Props) { + const styles = useStyles2(getStyles); + + return ( +
+ {info.map((infoItem, index) => ( + +
+
{infoItem.label}
+ {infoItem.value} +
+ {index + 1 < info.length &&
} + + ))} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(1.5), + overflow: 'auto', + }), + infoItem: css({ + ...theme.typography.bodySmall, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }), + label: css({ + color: theme.colors.text.secondary, + }), + separator: css({ + borderLeft: `1px solid ${theme.colors.border.weak}`, + }), + }; +}; From 904c6f1ea9f2f8c03623a0563e4b469c3395c693 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 8 Nov 2022 15:26:32 +0100 Subject: [PATCH 122/926] Chore: update latest.json to 9.2.4 (#58433) --- latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/latest.json b/latest.json index 67be342baa8..a0b6b076021 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "9.2.3", - "testing": "9.2.3" + "stable": "9.2.4", + "testing": "9.2.4" } From 92817469143a222e6d1be8b3369151d55c7fe47d Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue, 8 Nov 2022 08:37:11 -0600 Subject: [PATCH 123/926] Prometheus: remove /series endpoint calls in query builder label names and values for supported clients (#58087) * add other filter variables to match param for label values query against filter values, in order to resolve bug in which filter value options would display that aren't relevant in the current query editor context, i.e. options would display that upon select would display no data * expanding current unit test coverage to cover calls to new API * interpolate the label name string instead of the match promql expression --- .../prometheus/language_provider.mock.ts | 1 + .../prometheus/language_provider.ts | 59 +++++++++++++- .../prometheus/metric_find_query.test.ts | 80 +++++++++++++++++-- .../querybuilder/components/MetricSelect.tsx | 10 +-- .../components/PromQueryBuilder.test.tsx | 56 ++++++++++++- .../components/PromQueryBuilder.tsx | 61 ++++++++++++-- 6 files changed, 245 insertions(+), 22 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/language_provider.mock.ts b/public/app/plugins/datasource/prometheus/language_provider.mock.ts index 25924a65e2b..ed2944ba942 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.mock.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.mock.ts @@ -11,6 +11,7 @@ export class EmptyLanguageProviderMock { getSeries = jest.fn().mockReturnValue({ __name__: [] }); fetchSeries = jest.fn().mockReturnValue([]); fetchSeriesLabels = jest.fn().mockReturnValue([]); + fetchSeriesLabelsMatch = jest.fn().mockReturnValue([]); fetchLabels = jest.fn(); loadMetricsMetadata = jest.fn(); } diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 8700b8909b4..d7a286fc6db 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -1,4 +1,4 @@ -import { once, chain, difference } from 'lodash'; +import { chain, difference, once } from 'lodash'; import LRU from 'lru-cache'; import Prism from 'prismjs'; import { Value } from 'slate'; @@ -471,6 +471,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { } } + /** + * @todo cache + * @param key + */ fetchLabelValues = async (key: string): Promise => { const params = this.datasource.getTimeRangeParams(); const url = `/api/v1/label/${this.datasource.interpolateString(key)}/values`; @@ -498,7 +502,22 @@ export default class PromQlLanguageProvider extends LanguageProvider { } /** - * Fetch labels for a series. This is cached by its args but also by the global timeRange currently selected as + * Fetches all values for a label, with optional match[] + * @param name + * @param match + */ + fetchSeriesValues = async (name: string, match?: string): Promise => { + const interpolatedName = name ? this.datasource.interpolateString(name) : null; + const range = this.datasource.getTimeRangeParams(); + const urlParams = { + ...range, + ...(interpolatedName && { 'match[]': match }), + }; + return await this.request(`/api/v1/label/${interpolatedName}/values`, [], urlParams); + }; + + /** + * Fetch labels for a series using /series endpoint. This is cached by its args but also by the global timeRange currently selected as * they can change over requested time. * @param name * @param withName @@ -533,6 +552,42 @@ export default class PromQlLanguageProvider extends LanguageProvider { return value; }; + /** + * Fetch labels for a series using /labels endpoint. This is cached by its args but also by the global timeRange currently selected as + * they can change over requested time. + * @param name + * @param withName + */ + fetchSeriesLabelsMatch = async (name: string, withName?: boolean): Promise> => { + const interpolatedName = this.datasource.interpolateString(name); + const range = this.datasource.getTimeRangeParams(); + const urlParams = { + ...range, + 'match[]': interpolatedName, + }; + const url = `/api/v1/labels`; + // Cache key is a bit different here. We add the `withName` param and also round up to a minute the intervals. + // The rounding may seem strange but makes relative intervals like now-1h less prone to need separate request every + // millisecond while still actually getting all the keys for the correct interval. This still can create problems + // when user does not the newest values for a minute if already cached. + const cacheParams = new URLSearchParams({ + 'match[]': interpolatedName, + start: roundSecToMin(parseInt(range.start, 10)).toString(), + end: roundSecToMin(parseInt(range.end, 10)).toString(), + withName: withName ? 'true' : 'false', + }); + + const cacheKey = `${url}?${cacheParams.toString()}`; + let value = this.labelsCache.get(cacheKey); + if (!value) { + const data: string[] = await this.request(url, [], urlParams); + // Convert string array to Record + value = data.reduce((ac, a) => ({ ...ac, [a]: '' }), {}); + this.labelsCache.set(cacheKey, value); + } + return value; + }; + /** * Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels. * @param match diff --git a/public/app/plugins/datasource/prometheus/metric_find_query.test.ts b/public/app/plugins/datasource/prometheus/metric_find_query.test.ts index 6becb8d7cb9..e2adf5530b7 100644 --- a/public/app/plugins/datasource/prometheus/metric_find_query.test.ts +++ b/public/app/plugins/datasource/prometheus/metric_find_query.test.ts @@ -5,6 +5,8 @@ import { DataSourceInstanceSettings, toUtc } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ +import { PromApplication } from '../../../types/unified-alerting-dto'; + import { PrometheusDatasource } from './datasource'; import PrometheusMetricFindQuery from './metric_find_query'; import { PromOptions } from './types'; @@ -23,7 +25,7 @@ const instanceSettings = { user: 'test', password: 'mupp', jsonData: { httpMethod: 'GET' }, -} as unknown as DataSourceInstanceSettings; +} as Partial> as DataSourceInstanceSettings; const raw = { from: toUtc('2018-04-25 10:00'), to: toUtc('2018-04-25 11:00'), @@ -52,14 +54,22 @@ beforeEach(() => { }); describe('PrometheusMetricFindQuery', () => { - let ds: PrometheusDatasource; + let legacyPrometheusDatasource: PrometheusDatasource; + let prometheusDatasource: PrometheusDatasource; beforeEach(() => { - ds = new PrometheusDatasource(instanceSettings, templateSrvStub); + legacyPrometheusDatasource = new PrometheusDatasource(instanceSettings, templateSrvStub); + prometheusDatasource = new PrometheusDatasource( + { + ...instanceSettings, + jsonData: { ...instanceSettings.jsonData, prometheusVersion: '2.2.0', prometheusType: PromApplication.Mimir }, + }, + templateSrvStub + ); }); - const setupMetricFindQuery = (data: any) => { + const setupMetricFindQuery = (data: any, datasource?: PrometheusDatasource) => { fetchMock.mockImplementation(() => of({ status: 'success', data: data.response } as unknown as FetchResponse)); - return new PrometheusMetricFindQuery(ds, data.query); + return new PrometheusMetricFindQuery(datasource ?? legacyPrometheusDatasource, data.query); }; describe('When performing metricFindQuery', () => { @@ -102,6 +112,7 @@ describe('PrometheusMetricFindQuery', () => { }); }); + // it('label_values(metric, resource) should generate series query with correct time', async () => { const query = setupMetricFindQuery({ query: 'label_values(metric, resource)', @@ -179,6 +190,7 @@ describe('PrometheusMetricFindQuery', () => { headers: {}, }); }); + // it('metrics(metric.*) should generate metric name query', async () => { const query = setupMetricFindQuery({ @@ -277,5 +289,63 @@ describe('PrometheusMetricFindQuery', () => { headers: {}, }); }); + + // + it('label_values(metric, resource) should generate label values query with correct time', async () => { + const metricName = 'metricName'; + const resourceName = 'resourceName'; + const query = setupMetricFindQuery( + { + query: `label_values(${metricName}, ${resourceName})`, + response: { + data: [ + { __name__: `${metricName}`, resourceName: 'value1' }, + { __name__: `${metricName}`, resourceName: 'value2' }, + { __name__: `${metricName}`, resourceName: 'value3' }, + ], + }, + }, + prometheusDatasource + ); + const results = await query.process(); + + expect(results).toHaveLength(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/1/resources/api/v1/label/${resourceName}/values?match${encodeURIComponent( + '[]' + )}=${metricName}&start=${raw.from.unix()}&end=${raw.to.unix()}`, + hideFromInspector: true, + headers: {}, + }); + }); + + it('label_values(metric{label1="foo", label2="bar", label3="baz"}, resource) should generate label values query with correct time', async () => { + const metricName = 'metricName'; + const resourceName = 'resourceName'; + const label1Name = 'label1'; + const label1Value = 'label1Value'; + const query = setupMetricFindQuery( + { + query: `label_values(${metricName}{${label1Name}="${label1Value}"}, ${resourceName})`, + response: { + data: [{ __name__: metricName, resourceName: label1Value }], + }, + }, + prometheusDatasource + ); + const results = await query.process(); + + expect(results).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith({ + method: 'GET', + url: `/api/datasources/1/resources/api/v1/label/${resourceName}/values?match%5B%5D=${metricName}%7B${label1Name}%3D%22${label1Value}%22%7D&start=1524650400&end=1524654000`, + hideFromInspector: true, + headers: {}, + }); + }); + // }); }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index db1636ed705..ce42fe854d4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -22,7 +22,7 @@ export interface Props { labelsFilters: QueryBuilderLabelFilter[]; } -const MAX_NUMBER_OF_RESULTS = 1000; +export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000; export function MetricSelect({ datasource, query, onChange, onGetMetrics, labelsFilters }: Props) { const styles = useStyles2(getStyles); @@ -109,8 +109,8 @@ export function MetricSelect({ datasource, query, onChange, onGetMetrics, labels // Since some customers can have millions of metrics, whenever the user changes the autocomplete text we want to call the backend and request all metrics that match the current query string const results = datasource.metricFindQuery(formatKeyValueStringsForLabelValuesQuery(query, labelsFilters)); return results.then((results) => { - if (results.length > MAX_NUMBER_OF_RESULTS) { - results.splice(0, results.length - MAX_NUMBER_OF_RESULTS); + if (results.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { + results.splice(0, results.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); } return results.map((result) => { return { @@ -137,8 +137,8 @@ export function MetricSelect({ datasource, query, onChange, onGetMetrics, labels onOpenMenu={async () => { setState({ isLoading: true }); const metrics = await onGetMetrics(); - if (metrics.length > MAX_NUMBER_OF_RESULTS) { - metrics.splice(0, metrics.length - MAX_NUMBER_OF_RESULTS); + if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { + metrics.splice(0, metrics.length - PROMETHEUS_QUERY_BUILDER_MAX_RESULTS); } setState({ metrics, isLoading: undefined }); }} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx index 855aa243ff8..2d006875657 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx @@ -11,9 +11,11 @@ import { TimeRange, } from '@grafana/data'; +import { PromApplication } from '../../../../../types/unified-alerting-dto'; import { PrometheusDatasource } from '../../datasource'; import PromQlLanguageProvider from '../../language_provider'; import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromOptions } from '../../types'; import { getLabelSelects } from '../testUtils'; import { PromVisualQuery } from '../types'; @@ -101,6 +103,7 @@ describe('PromQueryBuilder', () => { await waitFor(() => expect(datasource.getVariables).toBeCalled()); }); + // it('tries to load labels when metric selected', async () => { const { languageProvider } = setup(); await openLabelNameSelect(); @@ -127,6 +130,7 @@ describe('PromQueryBuilder', () => { expect(languageProvider.fetchSeriesLabels).toBeCalledWith('{label_name="label_value", __name__="random_metric"}') ); }); + // it('tries to load labels when metric is not selected', async () => { const { languageProvider } = setup({ @@ -224,16 +228,56 @@ describe('PromQueryBuilder', () => { ); expect(await screen.queryByText(EXPLAIN_LABEL_FILTER_CONTENT)).not.toBeInTheDocument(); }); + + // + it('tries to load labels when metric selected modern prom', async () => { + const { languageProvider } = setup(undefined, undefined, { + jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus }, + }); + await openLabelNameSelect(); + await waitFor(() => expect(languageProvider.fetchSeriesLabelsMatch).toBeCalledWith('{__name__="random_metric"}')); + }); + + it('tries to load variables in label field modern prom', async () => { + const { datasource } = setup(undefined, undefined, { + jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus }, + }); + datasource.getVariables = jest.fn().mockReturnValue([]); + await openLabelNameSelect(); + await waitFor(() => expect(datasource.getVariables).toBeCalled()); + }); + + it('tries to load labels when metric selected and other labels are already present modern prom', async () => { + const { languageProvider } = setup( + { + ...defaultQuery, + labels: [ + { label: 'label_name', op: '=', value: 'label_value' }, + { label: 'foo', op: '=', value: 'bar' }, + ], + }, + undefined, + { jsonData: { prometheusVersion: '2.38.1', prometheusType: PromApplication.Prometheus } } + ); + await openLabelNameSelect(1); + await waitFor(() => + expect(languageProvider.fetchSeriesLabelsMatch).toBeCalledWith( + '{label_name="label_value", __name__="random_metric"}' + ) + ); + }); + // }); -function createDatasource() { +function createDatasource(options?: Partial>) { const languageProvider = new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider; const datasource = new PrometheusDatasource( { url: '', jsonData: {}, meta: {} as DataSourcePluginMeta, - } as DataSourceInstanceSettings, + ...options, + } as DataSourceInstanceSettings, undefined, undefined, languageProvider @@ -251,8 +295,12 @@ function createProps(datasource: PrometheusDatasource, data?: PanelData) { }; } -function setup(query: PromVisualQuery = defaultQuery, data?: PanelData) { - const { datasource, languageProvider } = createDatasource(); +function setup( + query: PromVisualQuery = defaultQuery, + data?: PanelData, + datasourceOptionsOverride?: Partial> +) { + const { datasource, languageProvider } = createDatasource(datasourceOptionsOverride); const props = createProps(datasource, data); const { container } = render(); return { languageProvider, datasource, container }; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx index af28adbe0d2..f6e081cb0a0 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.tsx @@ -53,6 +53,11 @@ export const PromQueryBuilder = React.memo((props) => { [datasource] ); + /** + * Function kicked off when user interacts with label in label filters. + * Formats a promQL expression and passes that off to helper functions depending on API support + * @param forLabel + */ const onGetLabelNames = async (forLabel: Partial): Promise> => { // If no metric we need to use a different method if (!query.metric) { @@ -64,7 +69,13 @@ export const PromQueryBuilder = React.memo((props) => { const labelsToConsider = query.labels.filter((x) => x !== forLabel); labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); const expr = promQueryModeller.renderLabels(labelsToConsider); - const labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr); + + let labelsIndex; + if (datasource.hasLabelsMatchAPISupport()) { + labelsIndex = await datasource.languageProvider.fetchSeriesLabelsMatch(expr); + } else { + labelsIndex = await datasource.languageProvider.fetchSeriesLabels(expr); + } // filter out already used labels return Object.keys(labelsIndex) @@ -72,12 +83,47 @@ export const PromQueryBuilder = React.memo((props) => { .map((k) => ({ value: k })); }; + /** + * Helper function to fetch and format label value results from legacy API + * @param forLabel + * @param promQLExpression + */ + const getLabelValuesFromSeriesAPI = async (forLabel: Partial, promQLExpression: string) => { + if (!forLabel.label) { + return []; + } + const result = await datasource.languageProvider.fetchSeriesLabels(promQLExpression); + const forLabelInterpolated = datasource.interpolateString(forLabel.label); + return result[forLabelInterpolated].map((v) => ({ value: v })) ?? []; + }; + + /** + * Helper function to fetch label values from a promql string expression and a label + * @param forLabel + * @param promQLExpression + */ + const getLabelValuesFromLabelValuesAPI = async ( + forLabel: Partial, + promQLExpression: string + ) => { + if (!forLabel.label) { + return []; + } + return (await datasource.languageProvider.fetchSeriesValues(forLabel.label, promQLExpression)).map((v) => ({ + value: v, + })); + }; + + /** + * Function kicked off when users interact with the value of the label filters + * Formats a promQL expression and passes that into helper functions depending on API support + * @param forLabel + */ const onGetLabelValues = async (forLabel: Partial) => { if (!forLabel.label) { return []; } - - // If no metric we need to use a different method + // If no metric is selected, we can get the raw list of labels if (!query.metric) { return (await datasource.languageProvider.getLabelValues(forLabel.label)).map((v) => ({ value: v })); } @@ -85,9 +131,12 @@ export const PromQueryBuilder = React.memo((props) => { const labelsToConsider = query.labels.filter((x) => x !== forLabel); labelsToConsider.push({ label: '__name__', op: '=', value: query.metric }); const expr = promQueryModeller.renderLabels(labelsToConsider); - const result = await datasource.languageProvider.fetchSeriesLabels(expr); - const forLabelInterpolated = datasource.interpolateString(forLabel.label); - return result[forLabelInterpolated].map((v) => ({ value: v })) ?? []; + + if (datasource.hasLabelsMatchAPISupport()) { + return getLabelValuesFromLabelValuesAPI(forLabel, expr); + } else { + return getLabelValuesFromSeriesAPI(forLabel, expr); + } }; const onGetMetrics = useCallback(() => { From 42b2e630b7b3a8be50a852e94d3a9080cd0087e3 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Tue, 8 Nov 2022 15:41:17 +0100 Subject: [PATCH 124/926] GoogleCloudMonitoring: Refactor annotation code (#58417) --- pkg/tsdb/cloudmonitoring/annotation_query.go | 37 ++++++++++--- .../cloudmonitoring/annotation_query_test.go | 9 ++-- .../cloudmonitoring/time_series_filter.go | 36 ------------- pkg/tsdb/cloudmonitoring/time_series_query.go | 52 ------------------- pkg/tsdb/cloudmonitoring/types.go | 1 - 5 files changed, 33 insertions(+), 102 deletions(-) diff --git a/pkg/tsdb/cloudmonitoring/annotation_query.go b/pkg/tsdb/cloudmonitoring/annotation_query.go index 603d478c71a..16ac1fc7352 100644 --- a/pkg/tsdb/cloudmonitoring/annotation_query.go +++ b/pkg/tsdb/cloudmonitoring/annotation_query.go @@ -3,6 +3,7 @@ package cloudmonitoring import ( "context" "encoding/json" + "strconv" "strings" "time" @@ -45,24 +46,46 @@ func (s *Service) executeAnnotationQuery(ctx context.Context, logger log.Logger, if err != nil { return resp, nil } - err = queries[0].parseToAnnotations(queryRes, dr, mq.MetricQuery.Title, mq.MetricQuery.Text) + err = parseToAnnotations(req.Queries[0].RefID, queryRes, dr, mq.MetricQuery.Title, mq.MetricQuery.Text) resp.Responses[firstQuery.RefID] = *queryRes return resp, err } -func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) transformAnnotationToFrame(annotations []*annotationEvent, result *backend.DataResponse) { - frame := data.NewFrame(timeSeriesQuery.RefID, +func parseToAnnotations(refID string, dr *backend.DataResponse, + response cloudMonitoringResponse, title, text string) error { + frame := data.NewFrame(refID, data.NewField("time", nil, []time.Time{}), data.NewField("title", nil, []string{}), data.NewField("tags", nil, []string{}), data.NewField("text", nil, []string{}), ) - for _, a := range annotations { - frame.AppendRow(a.Time, a.Title, a.Tags, a.Text) + + for _, series := range response.TimeSeries { + if len(series.Points) == 0 { + continue + } + + for i := len(series.Points) - 1; i >= 0; i-- { + point := series.Points[i] + value := strconv.FormatFloat(point.Value.DoubleValue, 'f', 6, 64) + if series.ValueType == "STRING" { + value = point.Value.StringValue + } + annotation := &annotationEvent{ + Time: point.Interval.EndTime, + Title: formatAnnotationText(title, value, series.Metric.Type, + series.Metric.Labels, series.Resource.Labels), + Tags: "", + Text: formatAnnotationText(text, value, series.Metric.Type, + series.Metric.Labels, series.Resource.Labels), + } + frame.AppendRow(annotation.Time, annotation.Title, annotation.Tags, annotation.Text) + } } - result.Frames = append(result.Frames, frame) - timeSeriesQuery.logger.Info("anno", "len", len(annotations)) + dr.Frames = append(dr.Frames, frame) + + return nil } func formatAnnotationText(annotationText string, pointValue string, metricType string, metricLabels map[string]string, resourceLabels map[string]string) string { diff --git a/pkg/tsdb/cloudmonitoring/annotation_query_test.go b/pkg/tsdb/cloudmonitoring/annotation_query_test.go index e502067ce10..6e2c4cdb45c 100644 --- a/pkg/tsdb/cloudmonitoring/annotation_query_test.go +++ b/pkg/tsdb/cloudmonitoring/annotation_query_test.go @@ -14,9 +14,8 @@ func TestExecutor_parseToAnnotations(t *testing.T) { require.Len(t, d.TimeSeries, 3) res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{} - err = query.parseToAnnotations(res, d, "atitle {{metric.label.instance_name}} {{metric.value}}", + err = parseToAnnotations("anno", res, d, "atitle {{metric.label.instance_name}} {{metric.value}}", "atext {{resource.label.zone}}") require.NoError(t, err) @@ -33,13 +32,12 @@ func TestExecutor_parseToAnnotations(t *testing.T) { func TestCloudMonitoringExecutor_parseToAnnotations_emptyTimeSeries(t *testing.T) { res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{} response := cloudMonitoringResponse{ TimeSeries: []timeSeries{}, } - err := query.parseToAnnotations(res, response, "atitle", "atext") + err := parseToAnnotations("anno", res, response, "atitle", "atext") require.NoError(t, err) require.Len(t, res.Frames, 1) @@ -55,7 +53,6 @@ func TestCloudMonitoringExecutor_parseToAnnotations_emptyTimeSeries(t *testing.T func TestCloudMonitoringExecutor_parseToAnnotations_noPointsInSeries(t *testing.T) { res := &backend.DataResponse{} - query := &cloudMonitoringTimeSeriesFilter{} response := cloudMonitoringResponse{ TimeSeries: []timeSeries{ @@ -63,7 +60,7 @@ func TestCloudMonitoringExecutor_parseToAnnotations_noPointsInSeries(t *testing. }, } - err := query.parseToAnnotations(res, response, "atitle", "atext") + err := parseToAnnotations("anno", res, response, "atitle", "atext") require.NoError(t, err) require.Len(t, res.Frames, 1) diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 38fdd2c53a6..011d9b1a3f4 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -265,42 +265,6 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) handleNonDistributionSe setDisplayNameAsFieldName(dataField) } -func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseToAnnotations(dr *backend.DataResponse, - response cloudMonitoringResponse, title, text string) error { - frame := data.NewFrame(timeSeriesFilter.RefID, - data.NewField("time", nil, []time.Time{}), - data.NewField("title", nil, []string{}), - data.NewField("tags", nil, []string{}), - data.NewField("text", nil, []string{}), - ) - - for _, series := range response.TimeSeries { - if len(series.Points) == 0 { - continue - } - - for i := len(series.Points) - 1; i >= 0; i-- { - point := series.Points[i] - value := strconv.FormatFloat(point.Value.DoubleValue, 'f', 6, 64) - if series.ValueType == "STRING" { - value = point.Value.StringValue - } - annotation := &annotationEvent{ - Time: point.Interval.EndTime, - Title: formatAnnotationText(title, value, series.Metric.Type, - series.Metric.Labels, series.Resource.Labels), - Tags: "", - Text: formatAnnotationText(text, value, series.Metric.Type, - series.Metric.Labels, series.Resource.Labels), - } - frame.AppendRow(annotation.Time, annotation.Title, annotation.Tags, annotation.Text) - } - } - dr.Frames = append(dr.Frames, frame) - - return nil -} - func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) buildDeepLink() string { if timeSeriesFilter.Slo != "" { return "" diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index af0dbe4b6ed..05ca9505f51 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -286,58 +286,6 @@ func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseResponse(queryRes *b return nil } -func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) parseToAnnotations(queryRes *backend.DataResponse, - data cloudMonitoringResponse, title, text string) error { - annotations := make([]*annotationEvent, 0) - - for _, series := range data.TimeSeriesData { - metricLabels := make(map[string]string) - resourceLabels := make(map[string]string) - - for n, d := range data.TimeSeriesDescriptor.LabelDescriptors { - key := toSnakeCase(d.Key) - labelValue := series.LabelValues[n] - value := "" - switch d.ValueType { - case "BOOL": - strVal := strconv.FormatBool(labelValue.BoolValue) - value = strVal - case "INT64": - value = labelValue.Int64Value - default: - value = labelValue.StringValue - } - if strings.Index(key, "metric.") == 0 { - key = key[len("metric."):] - metricLabels[key] = value - } else if strings.Index(key, "resource.") == 0 { - key = key[len("resource."):] - resourceLabels[key] = value - } - } - - for n, d := range data.TimeSeriesDescriptor.PointDescriptors { - // reverse the order to be ascending - for i := len(series.PointData) - 1; i >= 0; i-- { - point := series.PointData[i] - value := strconv.FormatFloat(point.Values[n].DoubleValue, 'f', 6, 64) - if d.ValueType == "STRING" { - value = point.Values[n].StringValue - } - annotations = append(annotations, &annotationEvent{ - Time: point.TimeInterval.EndTime, - Title: formatAnnotationText(title, value, d.MetricKind, metricLabels, resourceLabels), - Tags: "", - Text: formatAnnotationText(text, value, d.MetricKind, metricLabels, resourceLabels), - }) - } - } - } - - timeSeriesQuery.transformAnnotationToFrame(annotations, queryRes) - return nil -} - func (timeSeriesQuery *cloudMonitoringTimeSeriesQuery) buildDeepLink() string { u, err := url.Parse("https://console.cloud.google.com/monitoring/metrics-explorer") if err != nil { diff --git a/pkg/tsdb/cloudmonitoring/types.go b/pkg/tsdb/cloudmonitoring/types.go index 0b09c652bea..02afc94d630 100644 --- a/pkg/tsdb/cloudmonitoring/types.go +++ b/pkg/tsdb/cloudmonitoring/types.go @@ -16,7 +16,6 @@ type ( run(ctx context.Context, req *backend.QueryDataRequest, s *Service, dsInfo datasourceInfo, tracer tracing.Tracer) ( *backend.DataResponse, cloudMonitoringResponse, string, error) parseResponse(dr *backend.DataResponse, data cloudMonitoringResponse, executedQueryString string) error - parseToAnnotations(dr *backend.DataResponse, data cloudMonitoringResponse, title, text string) error buildDeepLink() string getRefID() string } From 3790e105e5ac1bf4f9485853f10f1a0854fe3677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Tue, 8 Nov 2022 15:56:19 +0100 Subject: [PATCH 125/926] Internationalization: Translate TimePickerFooter component (#58390) --- .../TimeRangePicker/TimePickerFooter.tsx | 17 ++++++++++++----- public/locales/de-DE/grafana.json | 7 +++++++ public/locales/en-US/grafana.json | 7 +++++++ public/locales/es-ES/grafana.json | 7 +++++++ public/locales/fr-FR/grafana.json | 7 +++++++ public/locales/pseudo-LOCALE/grafana.json | 7 +++++++ public/locales/zh-Hans/grafana.json | 7 +++++++ 7 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx index 4fec8509c07..471625df5a6 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx @@ -7,6 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Field, RadioButtonGroup, Select } from '../..'; import { stylesFactory, useTheme2 } from '../../../themes'; +import { t, Trans } from '../../../utils/i18n'; import { Button } from '../../Button'; import { TimeZonePicker } from '../TimeZonePicker'; import { TimeZoneDescription } from '../TimeZonePicker/TimeZoneDescription'; @@ -58,7 +59,10 @@ export const TimePickerFooter: FC = (props) => { return (
-
+
@@ -69,7 +73,7 @@ export const TimePickerFooter: FC = (props) => {
{isEditing ? ( @@ -78,8 +82,8 @@ export const TimePickerFooter: FC = (props) => { @@ -106,7 +110,10 @@ export const TimePickerFooter: FC = (props) => { aria-label={selectors.components.TimeZonePicker.containerV2} className={cx(style.timeZoneContainer, style.timeSettingContainer)} > - + { onBlur={onBlur} components={{ Option: TimeZoneOption, Group: TimeZoneGroup }} disabled={disabled} - aria-label={'Time zone picker'} + aria-label={t('time-picker.zone.select-aria-label', 'Time zone picker')} /> ); }; diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index c7c01648628..27cf2529b5c 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -441,6 +441,10 @@ "default-title": "", "example-title": "", "specify": "" + }, + "zone": { + "select-aria-label": "", + "select-search-input": "" } }, "user-orgs": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 69022fa928c..f651674b9b8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -441,6 +441,10 @@ "default-title": "Time ranges", "example-title": "Example time ranges", "specify": "Specify time range <1>" + }, + "zone": { + "select-aria-label": "Time zone picker", + "select-search-input": "Type to search (country, city, abbreviation)" } }, "user-orgs": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 0d7176e03b5..d91371180db 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -441,6 +441,10 @@ "default-title": "", "example-title": "", "specify": "" + }, + "zone": { + "select-aria-label": "", + "select-search-input": "" } }, "user-orgs": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 5220b829313..7b27e4bc8d9 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -441,6 +441,10 @@ "default-title": "", "example-title": "", "specify": "" + }, + "zone": { + "select-aria-label": "", + "select-search-input": "" } }, "user-orgs": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index daa26c5dd46..c35edcd0d73 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -441,6 +441,10 @@ "default-title": "Ŧįmę řäʼnģęş", "example-title": "Ēχämpľę ŧįmę řäʼnģęş", "specify": "Ŝpęčįƒy ŧįmę řäʼnģę <1>" + }, + "zone": { + "select-aria-label": "Ŧįmę žőʼnę pįčĸęř", + "select-search-input": "Ŧypę ŧő şęäřčĥ (čőūʼnŧřy, čįŧy, äþþřęvįäŧįőʼn)" } }, "user-orgs": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index ca10f87bfed..aef95b3bb87 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -441,6 +441,10 @@ "default-title": "", "example-title": "", "specify": "" + }, + "zone": { + "select-aria-label": "", + "select-search-input": "" } }, "user-orgs": { From 512584558f578bfe99a3f201ffd641810c570631 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Tue, 8 Nov 2022 17:32:41 +0100 Subject: [PATCH 134/926] Changelog: Updated changelog for 8.5.15 (#58467) --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbaa34b40e9..236a4698261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1606,7 +1606,6 @@ The dependency to [grafana/aws-sdk](https://github.com/grafana/grafana-aws-sdk-r ### Features and enhancements - **Chore:** Upgrade Go to 1.19.2. [#56857](https://github.com/grafana/grafana/pull/56857), [@sakjur](https://github.com/sakjur) -- **Chore:** Upgrade Go to 1.19.3. [#58070](https://github.com/grafana/grafana/pull/58070), [@sakjur](https://github.com/sakjur) From 43a0afeac4fccfb12b2e87914dbedd2aa148b5ec Mon Sep 17 00:00:00 2001 From: Hamas Shafiq Date: Tue, 8 Nov 2022 16:39:21 +0000 Subject: [PATCH 135/926] Tempo: Fix start time column sorting when using search (#56635) --- .../app/plugins/datasource/tempo/resultTransformer.test.ts | 5 +---- public/app/plugins/datasource/tempo/resultTransformer.ts | 7 ------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/public/app/plugins/datasource/tempo/resultTransformer.test.ts b/public/app/plugins/datasource/tempo/resultTransformer.test.ts index 9893f50bc93..007e928bf19 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.test.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.test.ts @@ -113,11 +113,8 @@ describe('createTableFrameFromSearch()', () => { expect(frame.fields[1].name).toBe('traceName'); expect(frame.fields[1].values.get(0)).toBe('c10d7ca4e3a00354 '); - // expect time in ago format if startTime less than 1 hour expect(frame.fields[2].name).toBe('startTime'); - expect(frame.fields[2].values.get(0)).toBe('15 minutes ago'); - - // expect time in format if startTime greater than 1 hour + expect(frame.fields[2].values.get(0)).toBe('2022-01-28 03:00:28'); expect(frame.fields[2].values.get(1)).toBe('2022-01-27 22:56:06'); expect(frame.fields[3].name).toBe('duration'); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index 3c96d574bb2..37e9d324f91 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -611,13 +611,6 @@ function transformToTraceData(data: TraceSearchMetadata) { let startTime = !isNaN(traceStartTime) ? dateTimeFormat(traceStartTime) : ''; - if (Math.abs(differenceInHours(new Date(traceStartTime), Date.now())) <= 1) { - startTime = formatDistance(new Date(traceStartTime), Date.now(), { - addSuffix: true, - includeSeconds: true, - }); - } - return { traceID: data.traceID, startTime: startTime, From 3e92a2dc7725677336534eae4008be6a679ed44b Mon Sep 17 00:00:00 2001 From: Adam Simpson Date: Tue, 8 Nov 2022 11:55:53 -0500 Subject: [PATCH 136/926] Tooltips: Make tooltips in FormField and FormLabel interactive and keyboard friendly (#57706) * Tooltips: add tabindex and interactive A couple tooltips used in configuration of datasources like ADX were not clickable or didn't show on keyboard focus. - fixes #56561 - Same solution as #47137 * test: add test around tabbing to tooltips --- .../components/FormField/FormField.test.tsx | 19 ++++++++++++++++++- .../src/components/FormField/FormField.tsx | 5 ++++- .../src/components/FormLabel/FormLabel.tsx | 9 +++++---- .../SecretFormField/SecretFormField.tsx | 3 +++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/grafana-ui/src/components/FormField/FormField.test.tsx b/packages/grafana-ui/src/components/FormField/FormField.test.tsx index 71fe532a64d..553d92e0e17 100644 --- a/packages/grafana-ui/src/components/FormField/FormField.test.tsx +++ b/packages/grafana-ui/src/components/FormField/FormField.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { FormField, Props } from './FormField'; @@ -29,4 +30,20 @@ describe('FormField', () => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByRole('checkbox')).toBeInTheDocument(); }); + + it('tooltips should be focusable via Tab key', async () => { + const tooltip = 'Test tooltip'; + setup(); + setup({ + tooltip, + }); + + //focus the first input + screen.getAllByRole('textbox')[0].focus(); + await userEvent.tab(); + + await waitFor(() => { + screen.getByText(tooltip); + }); + }); }); diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx index b15b54c3e1a..b341cfaa206 100644 --- a/packages/grafana-ui/src/components/FormField/FormField.tsx +++ b/packages/grafana-ui/src/components/FormField/FormField.tsx @@ -11,6 +11,8 @@ export interface Props extends InputHTMLAttributes { // If null no width will be specified not even default one inputWidth?: number | null; inputEl?: React.ReactNode; + /** Make tooltip interactive */ + interactive?: boolean; } const defaultProps = { @@ -29,12 +31,13 @@ export const FormField: FunctionComponent = ({ inputWidth, inputEl, className, + interactive, ...inputProps }) => { const styles = getStyles(); return (
- + {label} {inputEl || ( diff --git a/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx index ae6c1c46466..c01047df2ff 100644 --- a/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx +++ b/packages/grafana-ui/src/components/FormLabel/FormLabel.tsx @@ -12,6 +12,8 @@ interface Props { isInvalid?: boolean; tooltip?: PopoverContent; width?: number | 'auto'; + /** Make tooltip interactive */ + interactive?: boolean; } export const FormLabel: FunctionComponent = ({ @@ -22,6 +24,7 @@ export const FormLabel: FunctionComponent = ({ htmlFor, tooltip, width, + interactive, ...rest }) => { const classes = classNames(className, `gf-form-label width-${width ? width : '10'}`, { @@ -33,10 +36,8 @@ export const FormLabel: FunctionComponent = ({ diff --git a/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx b/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx index 27da639c5ff..6eb08850410 100644 --- a/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx +++ b/packages/grafana-ui/src/components/SecretFormField/SecretFormField.tsx @@ -18,6 +18,7 @@ export interface Props extends Omit, 'onRe inputWidth?: number; // Placeholder of the input field when in non configured state. placeholder?: string; + interactive?: boolean; } const getSecretFormFieldStyles = () => { @@ -46,6 +47,7 @@ export const SecretFormField: FunctionComponent = ({ isConfigured, tooltip, placeholder = 'Password', + interactive, ...inputProps }: Props) => { const styles = getSecretFormFieldStyles(); @@ -53,6 +55,7 @@ export const SecretFormField: FunctionComponent = ({ Date: Tue, 8 Nov 2022 17:15:21 +0000 Subject: [PATCH 137/926] grafana/e2e: Update add dashboard flow (#58360) * Update variable editor e2e flow - Use correct selector for Variables - Add variable query form - Add label for variable apply button * Use data-testid instead of aria-label * Add jsdoc --- .../src/selectors/pages.ts | 1 + .../grafana-e2e/src/flows/addDashboard.ts | 80 ++++++++++++++++++- .../variables/editor/VariableEditorEditor.tsx | 6 +- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 4bdc8923d45..a6d0faae018 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -124,6 +124,7 @@ export const Pages = { selectionOptionsCustomAllInputV2: 'data-testid Variable editor Form IncludeAll field', previewOfValuesOption: 'Variable editor Preview of Values option', submitButton: 'Variable editor Submit button', + applyButton: 'data-testid Variable editor Apply button', }, QueryVariable: { queryOptionsDataSourceSelect: Components.DataSourcePicker.container, diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts index 252f9b79156..36dca49b86b 100644 --- a/packages/grafana-e2e/src/flows/addDashboard.ts +++ b/packages/grafana-e2e/src/flows/addDashboard.ts @@ -31,6 +31,7 @@ interface AddVariableOptional { label?: string; query?: string; regex?: string; + variableQueryForm?: (config: AddVariableConfig) => void; } interface AddVariableRequired { @@ -40,6 +41,74 @@ interface AddVariableRequired { export type PartialAddVariableConfig = Partial & AddVariableOptional & AddVariableRequired; export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired; +/** + * This flow is used to add a dashboard with whatever configuration specified. + * @param config Configuration object. Currently supports configuring dashboard time range, annotations, and variables (support dependant on type). + * @see{@link AddDashboardConfig} + * + * @example + * ``` + * // Configuring a simple dashboard + * addDashboard({ + * timeRange: { + * from: '2022-10-03 00:00:00', + * to: '2022-10-03 23:59:59', + * zone: 'Coordinated Universal Time', + * }, + * title: 'Test Dashboard', + * }) + * ``` + * + * @example + * ``` + * // Configuring a dashboard with annotations + * addDashboard({ + * title: 'Test Dashboard', + * annotations: [ + * { + * // This should match the datasource name + * dataSource: 'azure-monitor', + * name: 'Test Annotation', + * dataSourceForm: () => { + * // Insert steps to create annotation using datasource form + * } + * } + * ] + * }) + * ``` + * + * @see{@link AddAnnotationConfig} + * + * @example + * ``` + * // Configuring a dashboard with variables + * addDashboard({ + * title: 'Test Dashboard', + * variables: [ + * { + * name: 'test-query-variable', + * label: 'Testing Query', + * hide: '', + * type: e2e.flows.VARIABLE_TYPE_QUERY, + * dataSource: 'azure-monitor', + * variableQueryForm: () => { + * // Insert steps to create variable using datasource form + * }, + * }, + * { + * name: 'test-constant-variable', + * label: 'Testing Constant', + * type: e2e.flows.VARIABLE_TYPE_CONSTANT, + * constantValue: 'constant', + * } + * ] + * }) + * ``` + * + * @see{@link AddVariableConfig} + * + * @see{@link https://github.com/grafana/grafana/blob/main/e2e/cloud-plugins-suite/azure-monitor.spec.ts Azure Monitor Tests for full examples} + */ export const addDashboard = (config?: Partial) => { const fullConfig: AddDashboardConfig = { annotations: [], @@ -160,7 +229,7 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); } - const { constantValue, dataSource, label, name, query, regex, type } = fullConfig; + const { constantValue, dataSource, label, name, query, regex, type, variableQueryForm } = fullConfig; // This field is key to many reactive changes if (type !== VARIABLE_TYPE_QUERY) { @@ -185,7 +254,7 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() .should('be.visible') .within(() => { - e2e.components.Select.input().should('be.visible').type(`${dataSource}{enter}`); + e2e.components.DataSourcePicker.inputV2().type(`${dataSource}{enter}`); }); } @@ -201,6 +270,10 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar if (regex) { e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2().type(regex); } + + if (variableQueryForm) { + variableQueryForm(fullConfig); + } } // Avoid flakiness @@ -215,13 +288,14 @@ const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVar }); e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); return fullConfig; }; const addVariables = (configs: PartialAddVariableConfig[]): AddVariableConfig[] => { if (configs.length > 0) { - e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click(); + e2e.components.Tab.title('Variables').click(); } return configs.map((config, i) => addVariable(config, i === 0)); diff --git a/public/app/features/variables/editor/VariableEditorEditor.tsx b/public/app/features/variables/editor/VariableEditorEditor.tsx index 4c3f7113a9e..5ebb4ce7ebd 100644 --- a/public/app/features/variables/editor/VariableEditorEditor.tsx +++ b/public/app/features/variables/editor/VariableEditorEditor.tsx @@ -204,7 +204,11 @@ export class VariableEditorEditorUnConnected extends PureComponent Run query {loading && } - From 4d2be7a277d3c464cafca8fd3b49a598e9708056 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue, 8 Nov 2022 21:53:05 +0200 Subject: [PATCH 138/926] Nested Folders: Use recursive query if the driver supports it (#58178) * Nested Folders: Try first recursive query and fallback if it's not supported * Apply suggestion from code review Fix error msgID --- pkg/services/folder/folderimpl/sqlstore.go | 56 +++++++++++++++------- pkg/services/folder/model.go | 1 + 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index 4c82a7a2dd9..1a6351ab41a 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -2,14 +2,16 @@ package folderimpl import ( "context" + "errors" "strings" "time" + "github.com/VividCortex/mysqlerr" + "github.com/go-sql-driver/mysql" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -183,9 +185,6 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F func (ss *sqlStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { var folders []*folder.Folder - if ss.db.GetDBType() == migrator.MySQL { - return ss.getParentsMySQL(ctx, q) - } recQuery := ` WITH RECURSIVE RecQry AS ( @@ -195,14 +194,23 @@ func (ss *sqlStore) GetParents(ctx context.Context, q folder.GetParentsQuery) ([ SELECT * FROM RecQry; ` - err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { + if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { err := sess.SQL(recQuery, q.UID, q.OrgID).Find(&folders) if err != nil { return folder.ErrDatabaseError.Errorf("failed to get folder parents: %w", err) } return nil - }) - return util.Reverse(folders[1:]), err + }); err != nil { + var driverErr *mysql.MySQLError + if errors.As(err, &driverErr) { + if driverErr.Number == mysqlerr.ER_PARSE_ERROR { + ss.log.Debug("recursive CTE subquery is not supported; it fallbacks to the iterative implementation") + return ss.getParentsMySQL(ctx, q) + } + } + return nil, err + } + return util.Reverse(folders[1:]), nil } func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetTreeQuery) ([]*folder.Folder, error) { @@ -228,20 +236,32 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetTreeQuery) ([]* return folders, err } -func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQuery) ([]*folder.Folder, error) { - var foldrs []*folder.Folder - var foldr *folder.Folder - err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - uid := cmd.UID - for uid != folder.GeneralFolderUID && len(foldrs) < 8 { - err := sess.Where("uid=? AND org_id=>", uid, cmd.OrgID).Find(foldr) +func (ss *sqlStore) getParentsMySQL(ctx context.Context, cmd folder.GetParentsQuery) (folders []*folder.Folder, err error) { + err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { + uid := "" + ok, err := sess.SQL("SELECT parent_uid FROM folder WHERE org_id=? AND uid=?", cmd.OrgID, cmd.UID).Get(&uid) + if err != nil { + return err + } + if !ok { + return folder.ErrFolderNotFound + } + for { + f := &folder.Folder{} + ok, err := sess.SQL("SELECT * FROM folder WHERE org_id=? AND uid=?", cmd.OrgID, uid).Get(f) if err != nil { - return folder.ErrDatabaseError.Errorf("failed to get folder parents: %w", err) + return err + } + if !ok { + break + } + folders = append(folders, f) + uid = f.ParentUID + if len(folders) > folder.MaxNestedFolderDepth { + return folder.ErrFolderTooDeep } - foldrs = append(foldrs, foldr) - uid = foldr.ParentUID } return nil }) - return foldrs, err + return folders, err } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index ee5392db273..b8d6ed7a76a 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -10,6 +10,7 @@ var ErrMaximumDepthReached = errutil.NewBase(errutil.StatusBadRequest, "folder.m var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "folder.bad-request") var ErrDatabaseError = errutil.NewBase(errutil.StatusInternal, "folder.database-error") var ErrInternal = errutil.NewBase(errutil.StatusInternal, "folder.internal") +var ErrFolderTooDeep = errutil.NewBase(errutil.StatusInternal, "folder.too-deep") const ( GeneralFolderUID = "general" From 72275e97d281f6b4dfb2cd1188db1e7ec5cbb198 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Tue, 8 Nov 2022 22:05:15 +0000 Subject: [PATCH 139/926] Use fnv64 for InmemCacheService (#58468) --- pkg/services/screenshot/cache.go | 6 +++--- pkg/services/screenshot/option.go | 12 ++++++++++++ pkg/services/screenshot/option_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/pkg/services/screenshot/cache.go b/pkg/services/screenshot/cache.go index 83447efbc3b..516cc886dfa 100644 --- a/pkg/services/screenshot/cache.go +++ b/pkg/services/screenshot/cache.go @@ -2,7 +2,7 @@ package screenshot import ( "context" - "fmt" + "encoding/base64" "time" gocache "github.com/patrickmn/go-cache" @@ -46,7 +46,7 @@ func NewInmemCacheService(expiration time.Duration, r prometheus.Registerer) Cac } func (s *InmemCacheService) Get(_ context.Context, opts ScreenshotOptions) (*Screenshot, bool) { - k := fmt.Sprintf("%s-%d-%s", opts.DashboardUID, opts.PanelID, opts.Theme) + k := base64.StdEncoding.EncodeToString(opts.Hash()) if v, ok := s.cache.Get(k); ok { defer s.cacheHits.Inc() return v.(*Screenshot), true @@ -56,7 +56,7 @@ func (s *InmemCacheService) Get(_ context.Context, opts ScreenshotOptions) (*Scr } func (s *InmemCacheService) Set(_ context.Context, opts ScreenshotOptions, screenshot *Screenshot) error { - k := fmt.Sprintf("%s-%d-%s", opts.DashboardUID, opts.PanelID, opts.Theme) + k := base64.StdEncoding.EncodeToString(opts.Hash()) s.cache.Set(k, screenshot, 0) return nil } diff --git a/pkg/services/screenshot/option.go b/pkg/services/screenshot/option.go index 063b1d850d5..e84d2ed3a55 100644 --- a/pkg/services/screenshot/option.go +++ b/pkg/services/screenshot/option.go @@ -1,6 +1,8 @@ package screenshot import ( + "hash/fnv" + "strconv" "time" "github.com/grafana/grafana/pkg/models" @@ -41,3 +43,13 @@ func (s ScreenshotOptions) SetDefaults() ScreenshotOptions { } return s } + +func (s ScreenshotOptions) Hash() []byte { + h := fnv.New64() + _, _ = h.Write([]byte(s.DashboardUID)) + _, _ = h.Write([]byte(strconv.FormatInt(s.PanelID, 10))) + _, _ = h.Write([]byte(strconv.FormatInt(int64(s.Width), 10))) + _, _ = h.Write([]byte(strconv.FormatInt(int64(s.Height), 10))) + _, _ = h.Write([]byte(s.Theme)) + return h.Sum(nil) +} diff --git a/pkg/services/screenshot/option_test.go b/pkg/services/screenshot/option_test.go index f2c8c542fff..50552c1431b 100644 --- a/pkg/services/screenshot/option_test.go +++ b/pkg/services/screenshot/option_test.go @@ -53,3 +53,27 @@ func TestScreenshotOptions(t *testing.T) { Timeout: DefaultTimeout + 1, }, o) } + +func TestScreenshotOptions_Hash(t *testing.T) { + o := ScreenshotOptions{} + assert.Equal(t, []byte{0xd9, 0x83, 0x82, 0x18, 0x6c, 0x3d, 0x7d, 0x47}, o.Hash()) + + o = o.SetDefaults() + assert.Equal(t, []byte{0x6, 0x7, 0x97, 0x6, 0x53, 0xf, 0x8b, 0xf1}, o.Hash()) + + o.Width = 100 + o = o.SetDefaults() + assert.Equal(t, []byte{0x25, 0x50, 0xb4, 0x4b, 0x43, 0xcd, 0x3, 0x49}, o.Hash()) + + o.Height = 100 + o = o.SetDefaults() + assert.Equal(t, []byte{0x51, 0xe2, 0x6f, 0x2c, 0x62, 0x7b, 0x3b, 0xc5}, o.Hash()) + + o.Theme = "Not a theme" + o = o.SetDefaults() + assert.Equal(t, []byte{0x51, 0xe2, 0x6f, 0x2c, 0x62, 0x7b, 0x3b, 0xc5}, o.Hash()) + + // the timeout should not change the sum + o.Timeout = DefaultTimeout + 1 + assert.Equal(t, []byte{0x51, 0xe2, 0x6f, 0x2c, 0x62, 0x7b, 0x3b, 0xc5}, o.Hash()) +} From ad9ac85ee090f8bb006c5b095c0fe3ac7e1b71cd Mon Sep 17 00:00:00 2001 From: George Robinson Date: Tue, 8 Nov 2022 22:37:49 +0000 Subject: [PATCH 140/926] Alerting: Use hash of opts in singleflight (#58474) --- pkg/services/ngalert/image/service.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/services/ngalert/image/service.go b/pkg/services/ngalert/image/service.go index d113a209fbe..0c6a9d280e3 100644 --- a/pkg/services/ngalert/image/service.go +++ b/pkg/services/ngalert/image/service.go @@ -2,6 +2,7 @@ package image import ( "context" + "encoding/base64" "errors" "fmt" "time" @@ -135,8 +136,8 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *models.AlertRu Timeout: screenshotTimeout, } - k := fmt.Sprintf("%s-%d-%s", opts.DashboardUID, opts.PanelID, opts.Theme) - result, err, _ := s.singleflight.Do(k, func() (interface{}, error) { + optsHash := base64.StdEncoding.EncodeToString(opts.Hash()) + result, err, _ := s.singleflight.Do(optsHash, func() (interface{}, error) { screenshot, err := s.limiter.Do(ctx, opts, s.screenshots.Take) if err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { From 238a3f820c0f4889ec6e1c0f9109ad5bbf5d5a26 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 8 Nov 2022 14:42:32 -0800 Subject: [PATCH 141/926] Store: use "at" suffix for time base fields more consistently (#58486) --- .../store/object/dummy/dummy_server.go | 20 +- pkg/services/store/object/json.go | 31 +- pkg/services/store/object/object.pb.go | 437 +++++++++--------- pkg/services/store/object/object.proto | 13 +- .../object/sqlstash/sql_storage_server.go | 18 +- .../object/tests/server_integration_test.go | 12 +- 6 files changed, 275 insertions(+), 256 deletions(-) diff --git a/pkg/services/store/object/dummy/dummy_server.go b/pkg/services/store/object/dummy/dummy_server.go index a5a76078c9e..323f7b49082 100644 --- a/pkg/services/store/object/dummy/dummy_server.go +++ b/pkg/services/store/object/dummy/dummy_server.go @@ -32,7 +32,7 @@ type RawObjectWithHistory struct { var ( // increment when RawObject changes - rawObjectVersion = 8 + rawObjectVersion = 9 ) func ProvideDummyObjectServer(cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry) object.ObjectStoreServer { @@ -82,9 +82,9 @@ func (i *dummyObjectServer) findObject(ctx context.Context, grn *object.GRN, ver if objVersion.Version == version { copy := &object.RawObject{ GRN: obj.Object.GRN, - Created: obj.Object.Created, + CreatedAt: obj.Object.CreatedAt, CreatedBy: obj.Object.CreatedBy, - Updated: objVersion.Updated, + UpdatedAt: objVersion.UpdatedAt, UpdatedBy: objVersion.UpdatedBy, ETag: objVersion.ETag, Version: objVersion.Version, @@ -174,9 +174,9 @@ func (i *dummyObjectServer) update(ctx context.Context, r *object.WriteObjectReq updated := &object.RawObject{ GRN: r.GRN, - Created: i.Object.Created, + CreatedAt: i.Object.CreatedAt, CreatedBy: i.Object.CreatedBy, - Updated: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), UpdatedBy: store.GetUserIDString(modifier), Size: int64(len(r.Body)), ETag: createContentsHash(r.Body), @@ -188,7 +188,7 @@ func (i *dummyObjectServer) update(ctx context.Context, r *object.WriteObjectReq Body: r.Body, ObjectVersionInfo: &object.ObjectVersionInfo{ Version: updated.Version, - Updated: updated.Updated, + UpdatedAt: updated.UpdatedAt, UpdatedBy: updated.UpdatedBy, Size: updated.Size, ETag: updated.ETag, @@ -226,8 +226,8 @@ func (i *dummyObjectServer) insert(ctx context.Context, r *object.WriteObjectReq modifier := store.GetUserIDString(store.UserFromContext(ctx)) rawObj := &object.RawObject{ GRN: r.GRN, - Updated: time.Now().UnixMilli(), - Created: time.Now().UnixMilli(), + UpdatedAt: time.Now().UnixMilli(), + CreatedAt: time.Now().UnixMilli(), CreatedBy: modifier, UpdatedBy: modifier, Size: int64(len(r.Body)), @@ -238,7 +238,7 @@ func (i *dummyObjectServer) insert(ctx context.Context, r *object.WriteObjectReq info := &object.ObjectVersionInfo{ Version: rawObj.Version, - Updated: rawObj.Updated, + UpdatedAt: rawObj.UpdatedAt, UpdatedBy: rawObj.UpdatedBy, Size: rawObj.Size, ETag: rawObj.ETag, @@ -362,7 +362,7 @@ func (i *dummyObjectServer) Search(ctx context.Context, r *object.ObjectSearchRe searchResults = append(searchResults, &object.ObjectSearchResult{ GRN: o.Object.GRN, Version: o.Object.Version, - Updated: o.Object.Updated, + UpdatedAt: o.Object.UpdatedAt, UpdatedBy: o.Object.UpdatedBy, Name: summary.Name, Description: summary.Description, diff --git a/pkg/services/store/object/json.go b/pkg/services/store/object/json.go index 92a1a0fc541..45b9995bfa4 100644 --- a/pkg/services/store/object/json.go +++ b/pkg/services/store/object/json.go @@ -60,15 +60,15 @@ func (codec *rawObjectCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) stream.WriteObjectField("version") stream.WriteString(obj.Version) } - if obj.Created > 0 { + if obj.CreatedAt > 0 { stream.WriteMore() - stream.WriteObjectField("created") - stream.WriteInt64(obj.Created) + stream.WriteObjectField("createdAt") + stream.WriteInt64(obj.CreatedAt) } - if obj.Updated > 0 { + if obj.UpdatedAt > 0 { stream.WriteMore() - stream.WriteObjectField("updated") - stream.WriteInt64(obj.Updated) + stream.WriteObjectField("updatedAt") + stream.WriteInt64(obj.UpdatedAt) } if obj.CreatedBy != "" { stream.WriteMore() @@ -123,12 +123,12 @@ func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { case "GRN": raw.GRN = &GRN{} iter.ReadVal(raw.GRN) - case "updated": - raw.Updated = iter.ReadInt64() + case "updatedAt": + raw.UpdatedAt = iter.ReadInt64() case "updatedBy": raw.UpdatedBy = iter.ReadString() - case "created": - raw.Created = iter.ReadInt64() + case "createdAt": + raw.CreatedAt = iter.ReadInt64() case "createdBy": raw.CreatedBy = iter.ReadString() case "size": @@ -224,10 +224,15 @@ func (codec *searchResultCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stre stream.WriteObjectField("description") stream.WriteString(obj.Description) } - if obj.Updated > 0 { + if obj.Size > 0 { stream.WriteMore() - stream.WriteObjectField("updated") - stream.WriteInt64(obj.Updated) + stream.WriteObjectField("size") + stream.WriteInt64(obj.Size) + } + if obj.UpdatedAt > 0 { + stream.WriteMore() + stream.WriteObjectField("updatedAt") + stream.WriteInt64(obj.UpdatedAt) } if obj.UpdatedBy != "" { stream.WriteMore() diff --git a/pkg/services/store/object/object.pb.go b/pkg/services/store/object/object.pb.go index 95e1f0a711f..78248fedb08 100644 --- a/pkg/services/store/object/object.pb.go +++ b/pkg/services/store/object/object.pb.go @@ -161,9 +161,9 @@ type RawObject struct { // Object identifier GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` // Time in epoch milliseconds that the object was created - Created int64 `protobuf:"varint,2,opt,name=created,proto3" json:"created,omitempty"` + CreatedAt int64 `protobuf:"varint,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // Time in epoch milliseconds that the object was updated - Updated int64 `protobuf:"varint,3,opt,name=updated,proto3" json:"updated,omitempty"` + UpdatedAt int64 `protobuf:"varint,3,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // Who created the object CreatedBy string `protobuf:"bytes,4,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` // Who updated the object @@ -221,16 +221,16 @@ func (x *RawObject) GetGRN() *GRN { return nil } -func (x *RawObject) GetCreated() int64 { +func (x *RawObject) GetCreatedAt() int64 { if x != nil { - return x.Created + return x.CreatedAt } return 0 } -func (x *RawObject) GetUpdated() int64 { +func (x *RawObject) GetUpdatedAt() int64 { if x != nil { - return x.Updated + return x.UpdatedAt } return 0 } @@ -418,7 +418,7 @@ type ObjectVersionInfo struct { // The version will change when the object is saved. It is not necessarily sortable Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // Time in epoch milliseconds that the object was updated - Updated int64 `protobuf:"varint,2,opt,name=updated,proto3" json:"updated,omitempty"` + UpdatedAt int64 `protobuf:"varint,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // Who updated the object UpdatedBy string `protobuf:"bytes,3,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` // Content Length @@ -470,9 +470,9 @@ func (x *ObjectVersionInfo) GetVersion() string { return "" } -func (x *ObjectVersionInfo) GetUpdated() int64 { +func (x *ObjectVersionInfo) GetUpdatedAt() int64 { if x != nil { - return x.Updated + return x.UpdatedAt } return 0 } @@ -1264,9 +1264,11 @@ type ObjectSearchResult struct { // Object identifier GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` // The current veresion of this object - Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // Content Length + Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` // Time in epoch milliseconds that the object was updated - Updated int64 `protobuf:"varint,4,opt,name=updated,proto3" json:"updated,omitempty"` + UpdatedAt int64 `protobuf:"varint,4,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // Who updated the object UpdatedBy string `protobuf:"bytes,5,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` // Optionally include the full object body @@ -1329,9 +1331,16 @@ func (x *ObjectSearchResult) GetVersion() string { return "" } -func (x *ObjectSearchResult) GetUpdated() int64 { +func (x *ObjectSearchResult) GetSize() int64 { if x != nil { - return x.Updated + return x.Size + } + return 0 +} + +func (x *ObjectSearchResult) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt } return 0 } @@ -1451,210 +1460,212 @@ var file_object_proto_rawDesc = []byte{ 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, 0x22, 0xa1, 0x02, 0x0a, 0x09, 0x52, 0x61, 0x77, 0x4f, 0x62, + 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, 0x22, 0xab, 0x02, 0x0a, 0x09, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, - 0x47, 0x52, 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, - 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, - 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, 0x54, 0x61, - 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, 0x12, 0x0a, - 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, - 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x73, - 0x79, 0x6e, 0x63, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x79, 0x6e, 0x63, - 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x04, 0x73, 0x79, 0x6e, 0x63, 0x22, 0x3f, 0x0a, 0x11, 0x52, 0x61, - 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x79, 0x6e, 0x63, 0x49, 0x6e, 0x66, 0x6f, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, - 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, - 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, - 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x0b, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, - 0xa8, 0x01, 0x0a, 0x11, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x18, 0x0a, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, - 0x45, 0x54, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, - 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x8c, 0x01, 0x0a, 0x11, 0x52, - 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, - 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, - 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, - 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, - 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, 0x69, - 0x74, 0x68, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0x62, 0x0a, 0x12, 0x52, 0x65, 0x61, - 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x29, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x11, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, - 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x49, 0x0a, - 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x4f, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, - 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x8c, 0x01, 0x0a, 0x12, 0x57, 0x72, - 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, - 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, - 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x29, 0x0a, - 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, - 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xb3, 0x02, 0x0a, 0x13, 0x57, 0x72, 0x69, - 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x17, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, - 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, - 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x31, - 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, - 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, - 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x22, 0x3c, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, - 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, - 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, - 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, 0x44, 0x10, 0x03, 0x22, 0x5f, - 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, - 0x03, 0x47, 0x52, 0x4e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, - 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, - 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, - 0x26, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x4b, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x02, 0x4f, 0x4b, 0x22, 0x73, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x47, 0x52, 0x4e, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, + 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, + 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, + 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, 0x54, 0x61, 0x67, 0x18, 0x07, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x73, 0x79, 0x6e, 0x63, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x79, 0x6e, 0x63, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x04, + 0x73, 0x79, 0x6e, 0x63, 0x22, 0x3f, 0x0a, 0x11, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x53, 0x79, 0x6e, 0x63, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, + 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, + 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x64, 0x65, + 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0xad, 0x01, 0x0a, 0x11, 0x4f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, + 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, + 0x54, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, + 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x8c, 0x01, 0x0a, 0x11, 0x52, 0x65, + 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x14, - 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, - 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, - 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, - 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x95, 0x01, 0x0a, - 0x15, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, - 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x35, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, - 0x66, 0x6f, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, - 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x84, 0x03, 0x0a, 0x13, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, - 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, - 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, - 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, - 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3f, 0x0a, 0x06, - 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, - 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, - 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, - 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, - 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0a, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, - 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8b, 0x03, 0x0a, 0x12, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, + 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, + 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x75, + 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, 0x69, 0x74, + 0x68, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0x62, 0x0a, 0x12, 0x52, 0x65, 0x61, 0x64, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x29, + 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, + 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, + 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x49, 0x0a, 0x16, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, + 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x4f, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, + 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x52, + 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x8c, 0x01, 0x0a, 0x12, 0x57, 0x72, 0x69, + 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x12, + 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, + 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, + 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xb3, 0x02, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, + 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, + 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x72, + 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, + 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x31, 0x0a, + 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, + 0x73, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, + 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, + 0x3c, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, + 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, + 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0d, + 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, 0x44, 0x10, 0x03, 0x22, 0x5f, 0x0a, + 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, + 0x47, 0x52, 0x4e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, + 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x26, + 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x4b, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x02, 0x4f, 0x4b, 0x22, 0x73, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, + 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x14, 0x0a, + 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, + 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, + 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x15, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, + 0x03, 0x47, 0x52, 0x4e, 0x12, 0x35, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, + 0x6f, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, + 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x22, 0x84, 0x03, 0x0a, 0x13, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, + 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, + 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, + 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3f, 0x0a, 0x06, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, + 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, + 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, + 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, + 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, + 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa4, 0x03, 0x0a, 0x12, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, + 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, + 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, + 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1d, + 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, + 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, + 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, + 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, + 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, - 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x75, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x75, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, - 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, - 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3e, - 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, - 0x0a, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x12, - 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0b, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4a, 0x73, 0x6f, 0x6e, 0x1a, 0x39, - 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x74, 0x0a, 0x14, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, - 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, - 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x32, - 0xae, 0x03, 0x0a, 0x0b, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, - 0x3d, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, - 0x0a, 0x09, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x05, - 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, - 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, - 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1c, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, - 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, - 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x53, - 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x3b, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6c, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, + 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, + 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x66, 0x69, 0x65, + 0x6c, 0x64, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x4a, 0x73, 0x6f, 0x6e, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x22, 0x74, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, + 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, + 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, + 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0xae, 0x03, 0x0a, 0x0b, 0x4f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3d, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, + 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, + 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1a, 0x2e, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1b, 0x2e, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, + 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x3b, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/services/store/object/object.proto b/pkg/services/store/object/object.proto index eac4f9ff9fc..9e69e008bbd 100644 --- a/pkg/services/store/object/object.proto +++ b/pkg/services/store/object/object.proto @@ -27,10 +27,10 @@ message RawObject { GRN GRN = 1; // Time in epoch milliseconds that the object was created - int64 created = 2; + int64 created_at = 2; // Time in epoch milliseconds that the object was updated - int64 updated = 3; + int64 updated_at = 3; // Who created the object string created_by = 4; @@ -83,7 +83,7 @@ message ObjectVersionInfo { string version = 1; // Time in epoch milliseconds that the object was updated - int64 updated = 2; + int64 updated_at = 2; // Who updated the object string updated_by = 3; @@ -266,10 +266,13 @@ message ObjectSearchResult { GRN GRN = 1; // The current veresion of this object - string version = 3; + string version = 2; + + // Content Length + int64 size = 3; // Time in epoch milliseconds that the object was updated - int64 updated = 4; + int64 updated_at = 4; // Who updated the object string updated_by = 5; diff --git a/pkg/services/store/object/sqlstash/sql_storage_server.go b/pkg/services/store/object/sqlstash/sql_storage_server.go index 720e9eaeb01..4a10c75479e 100644 --- a/pkg/services/store/object/sqlstash/sql_storage_server.go +++ b/pkg/services/store/object/sqlstash/sql_storage_server.go @@ -72,8 +72,8 @@ func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql args := []interface{}{ &path, &raw.GRN.Kind, &raw.Version, &raw.Size, &raw.ETag, &summaryjson.errors, - &raw.Created, &raw.CreatedBy, - &raw.Updated, &raw.UpdatedBy, + &raw.CreatedAt, &raw.CreatedBy, + &raw.UpdatedAt, &raw.UpdatedBy, &syncSrc, &syncTime, } if r.WithBody { @@ -193,12 +193,12 @@ func (s *sqlObjectServer) readFromHistory(ctx context.Context, r *object.ReadObj rsp := &object.ReadObjectResponse{ Object: raw, } - err = rows.Scan(&raw.Body, &raw.Size, &raw.ETag, &raw.Updated, &raw.UpdatedBy) + err = rows.Scan(&raw.Body, &raw.Size, &raw.ETag, &raw.UpdatedAt, &raw.UpdatedBy) if err != nil { return nil, err } // For versioned files, the created+updated are the same - raw.Created = raw.Updated + raw.CreatedAt = raw.UpdatedAt raw.CreatedBy = raw.UpdatedBy raw.Version = r.Version // from the query @@ -357,7 +357,7 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques // 1. Add the `object_history` values versionInfo.Size = int64(len(body)) versionInfo.ETag = etag - versionInfo.Updated = timestamp + versionInfo.UpdatedAt = timestamp versionInfo.UpdatedBy = store.GetUserIDString(modifier) _, err = tx.Exec(ctx, `INSERT INTO object_history (`+ "path, version, message, "+ @@ -454,7 +454,7 @@ func (s *sqlObjectServer) selectForUpdate(ctx context.Context, tx *session.Sessi } current := &object.ObjectVersionInfo{} if rows.Next() { - err = rows.Scan(¤t.ETag, ¤t.Version, ¤t.Updated, ¤t.Size) + err = rows.Scan(¤t.ETag, ¤t.Version, ¤t.UpdatedAt, ¤t.Size) } if err == nil { err = rows.Close() @@ -541,7 +541,7 @@ func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRe } for rows.Next() { v := &object.ObjectVersionInfo{} - err := rows.Scan(&v.Version, &v.Size, &v.ETag, &v.Updated, &v.UpdatedBy, &v.Comment) + err := rows.Scan(&v.Version, &v.Size, &v.ETag, &v.UpdatedAt, &v.UpdatedBy, &v.Comment) if err != nil { return nil, err } @@ -558,7 +558,7 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ fields := []string{ "path", "kind", "version", "errors", // errors are always returned - "updated_at", "updated_by", + "size", "updated_at", "updated_by", "name", "description", // basic summary } @@ -623,7 +623,7 @@ func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequ args := []interface{}{ &key, &result.GRN.Kind, &result.Version, &summaryjson.errors, - &result.Updated, &result.UpdatedBy, + &result.Size, &result.UpdatedAt, &result.UpdatedBy, &result.Name, &summaryjson.description, } if r.WithBody { diff --git a/pkg/services/store/object/tests/server_integration_test.go b/pkg/services/store/object/tests/server_integration_test.go index 24087ae9bcc..46607d1186f 100644 --- a/pkg/services/store/object/tests/server_integration_test.go +++ b/pkg/services/store/object/tests/server_integration_test.go @@ -59,12 +59,12 @@ func requireObjectMatch(t *testing.T, obj *object.RawObject, m rawObjectMatcher) } } - if len(m.createdRange) == 2 && !timestampInRange(obj.Created, m.createdRange) { - mismatches += fmt.Sprintf("expected Created range: [from %s to %s], actual created: %s\n", m.createdRange[0], m.createdRange[1], time.UnixMilli(obj.Created)) + if len(m.createdRange) == 2 && !timestampInRange(obj.CreatedAt, m.createdRange) { + mismatches += fmt.Sprintf("expected Created range: [from %s to %s], actual created: %s\n", m.createdRange[0], m.createdRange[1], time.UnixMilli(obj.CreatedAt)) } - if len(m.updatedRange) == 2 && !timestampInRange(obj.Updated, m.updatedRange) { - mismatches += fmt.Sprintf("expected Updated range: [from %s to %s], actual updated: %s\n", m.updatedRange[0], m.updatedRange[1], time.UnixMilli(obj.Updated)) + if len(m.updatedRange) == 2 && !timestampInRange(obj.UpdatedAt, m.updatedRange) { + mismatches += fmt.Sprintf("expected Updated range: [from %s to %s], actual updated: %s\n", m.updatedRange[0], m.updatedRange[1], time.UnixMilli(obj.UpdatedAt)) } if m.createdBy != "" && m.createdBy != obj.CreatedBy { @@ -98,8 +98,8 @@ func requireVersionMatch(t *testing.T, obj *object.ObjectVersionInfo, m objectVe mismatches += fmt.Sprintf("expected etag: %s, actual etag: %s\n", *m.etag, obj.ETag) } - if len(m.updatedRange) == 2 && !timestampInRange(obj.Updated, m.updatedRange) { - mismatches += fmt.Sprintf("expected updatedRange range: [from %s to %s], actual updated: %s\n", m.updatedRange[0], m.updatedRange[1], time.UnixMilli(obj.Updated)) + if len(m.updatedRange) == 2 && !timestampInRange(obj.UpdatedAt, m.updatedRange) { + mismatches += fmt.Sprintf("expected updatedRange range: [from %s to %s], actual updated: %s\n", m.updatedRange[0], m.updatedRange[1], time.UnixMilli(obj.UpdatedAt)) } if m.updatedBy != "" && m.updatedBy != obj.UpdatedBy { From c646ff0ce3b5d68d6d96a9c98916186b91a3706c Mon Sep 17 00:00:00 2001 From: George Robinson Date: Wed, 9 Nov 2022 01:52:16 +0000 Subject: [PATCH 142/926] Alerting: Fix screenshots were not cached (#58493) --- pkg/services/ngalert/image/cache.go | 76 ++++++++++++++++++++++ pkg/services/ngalert/image/cache_mock.go | 65 ++++++++++++++++++ pkg/services/ngalert/image/cache_test.go | 38 +++++++++++ pkg/services/ngalert/image/service.go | 36 ++++++++-- pkg/services/ngalert/image/service_test.go | 36 +++++++++- 5 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 pkg/services/ngalert/image/cache.go create mode 100644 pkg/services/ngalert/image/cache_mock.go create mode 100644 pkg/services/ngalert/image/cache_test.go diff --git a/pkg/services/ngalert/image/cache.go b/pkg/services/ngalert/image/cache.go new file mode 100644 index 00000000000..4de259d8bd1 --- /dev/null +++ b/pkg/services/ngalert/image/cache.go @@ -0,0 +1,76 @@ +package image + +import ( + "context" + "time" + + gocache "github.com/patrickmn/go-cache" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +const ( + namespace = "grafana" + subsystem = "alerting" +) + +// CacheService caches images. +// +//go:generate mockgen -destination=cache_mock.go -package=image github.com/grafana/grafana/pkg/services/ngalert/image CacheService +type CacheService interface { + // Get returns the screenshot for the options or false if a screenshot with these + // options does not exist. + Get(ctx context.Context, k string) (models.Image, bool) + // Set the screenshot for the options. If another screenshot exists with these + // options then it will be replaced. + Set(ctx context.Context, k string, image models.Image) error +} + +// InmemCacheService is an in-mem screenshot cache. +type InmemCacheService struct { + cache *gocache.Cache + cacheHits prometheus.Counter + cacheMisses prometheus.Counter +} + +func NewInmemCacheService(expiration time.Duration, r prometheus.Registerer) CacheService { + return &InmemCacheService{ + cache: gocache.New(expiration, time.Minute), + cacheHits: promauto.With(r).NewCounter(prometheus.CounterOpts{ + Name: "image_cache_hits_total", + Namespace: namespace, + Subsystem: subsystem, + }), + cacheMisses: promauto.With(r).NewCounter(prometheus.CounterOpts{ + Name: "image_cache_misses_total", + Namespace: namespace, + Subsystem: subsystem, + }), + } +} + +func (s *InmemCacheService) Get(_ context.Context, k string) (models.Image, bool) { + if v, ok := s.cache.Get(k); ok { + defer s.cacheHits.Inc() + return v.(models.Image), true + } + defer s.cacheMisses.Inc() + return models.Image{}, false +} + +func (s *InmemCacheService) Set(_ context.Context, k string, screenshot models.Image) error { + s.cache.Set(k, screenshot, 0) + return nil +} + +type NoOpCacheService struct{} + +func (s *NoOpCacheService) Get(_ context.Context, _ string) (models.Image, bool) { + return models.Image{}, false +} + +func (s *NoOpCacheService) Set(_ context.Context, _ string, _ models.Image) error { + return nil +} diff --git a/pkg/services/ngalert/image/cache_mock.go b/pkg/services/ngalert/image/cache_mock.go new file mode 100644 index 00000000000..655e43fe241 --- /dev/null +++ b/pkg/services/ngalert/image/cache_mock.go @@ -0,0 +1,65 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/grafana/grafana/pkg/services/ngalert/image (interfaces: CacheService) + +// Package image is a generated GoMock package. +package image + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + models "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// MockCacheService is a mock of CacheService interface. +type MockCacheService struct { + ctrl *gomock.Controller + recorder *MockCacheServiceMockRecorder +} + +// MockCacheServiceMockRecorder is the mock recorder for MockCacheService. +type MockCacheServiceMockRecorder struct { + mock *MockCacheService +} + +// NewMockCacheService creates a new mock instance. +func NewMockCacheService(ctrl *gomock.Controller) *MockCacheService { + mock := &MockCacheService{ctrl: ctrl} + mock.recorder = &MockCacheServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCacheService) EXPECT() *MockCacheServiceMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockCacheService) Get(arg0 context.Context, arg1 string) (models.Image, bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(models.Image) + ret1, _ := ret[1].(bool) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockCacheServiceMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockCacheService)(nil).Get), arg0, arg1) +} + +// Set mocks base method. +func (m *MockCacheService) Set(arg0 context.Context, arg1 string, arg2 models.Image) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Set", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Set indicates an expected call of Set. +func (mr *MockCacheServiceMockRecorder) Set(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockCacheService)(nil).Set), arg0, arg1, arg2) +} diff --git a/pkg/services/ngalert/image/cache_test.go b/pkg/services/ngalert/image/cache_test.go new file mode 100644 index 00000000000..a5a889dd8e2 --- /dev/null +++ b/pkg/services/ngalert/image/cache_test.go @@ -0,0 +1,38 @@ +package image + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +func TestInmemCacheService(t *testing.T) { + s := NewInmemCacheService(time.Second, prometheus.DefaultRegisterer) + ctx := context.Background() + + // should be a miss + actual, ok := s.Get(ctx, "test") + assert.False(t, ok) + assert.Equal(t, models.Image{}, actual) + + // should be a hit + expected := models.Image{Path: "test.png"} + require.NoError(t, s.Set(ctx, "test", expected)) + actual, ok = s.Get(ctx, "test") + assert.True(t, ok) + assert.Equal(t, expected, actual) + + // wait 1s and the cached image should have expired + <-time.After(time.Second) + + // should be a miss + actual, ok = s.Get(ctx, "test") + assert.False(t, ok) + assert.Equal(t, models.Image{}, actual) +} diff --git a/pkg/services/ngalert/image/service.go b/pkg/services/ngalert/image/service.go index 0c6a9d280e3..75076d8d2f0 100644 --- a/pkg/services/ngalert/image/service.go +++ b/pkg/services/ngalert/image/service.go @@ -21,8 +21,8 @@ import ( ) const ( - screenshotTimeout = 10 * time.Second screenshotCacheTTL = 60 * time.Second + screenshotTimeout = 10 * time.Second ) var ( @@ -59,6 +59,7 @@ type ImageService interface { // as an annotation or label to the Alertmanager. This service cannot take // screenshots of alert rules that are not associated with a dashboard panel. type ScreenshotImageService struct { + cache CacheService limiter screenshot.RateLimiter logger log.Logger screenshots screenshot.ScreenshotService @@ -69,12 +70,14 @@ type ScreenshotImageService struct { // NewScreenshotImageService returns a new ScreenshotImageService. func NewScreenshotImageService( + cache CacheService, limiter screenshot.RateLimiter, logger log.Logger, screenshots screenshot.ScreenshotService, store store.ImageStore, uploads *UploadingService) ImageService { return &ScreenshotImageService{ + cache: cache, limiter: limiter, logger: logger, screenshots: screenshots, @@ -88,6 +91,7 @@ func NewScreenshotImageService( func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, db *store.DBstore, ds dashboards.DashboardService, rs rendering.Service, r prometheus.Registerer) (ImageService, error) { var ( + cache CacheService = &NoOpCacheService{} limiter screenshot.RateLimiter = &screenshot.NoOpRateLimiter{} screenshots screenshot.ScreenshotService = &screenshot.ScreenshotUnavailableService{} uploads *UploadingService = nil @@ -95,6 +99,7 @@ func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, db *store.DBstore, ds da // If screenshots are enabled if cfg.UnifiedAlerting.Screenshots.Capture { + cache = NewInmemCacheService(screenshotCacheTTL, r) limiter = screenshot.NewTokenRateLimiter(cfg.UnifiedAlerting.Screenshots.MaxConcurrentScreenshots) screenshots = screenshot.NewHeadlessScreenshotService(ds, rs, r) @@ -108,7 +113,7 @@ func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, db *store.DBstore, ds da } } - return NewScreenshotImageService(limiter, cfg.Logger, screenshots, db, uploads), nil + return NewScreenshotImageService(cache, limiter, log.New("ngalert.image"), screenshots, db, uploads), nil } // NewImage returns a screenshot of the alert rule or an error. @@ -119,7 +124,7 @@ func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, db *store.DBstore, ds da // alert rule has a Dashboard UID and the dashboard exists, but does not have a // Panel ID in its annotations then an ErrNoPanel error is returned. func (s *ScreenshotImageService) NewImage(ctx context.Context, r *models.AlertRule) (*models.Image, error) { - if r.DashboardUID == nil { + if r.DashboardUID == nil || *r.DashboardUID == "" { return nil, ErrNoDashboard } @@ -127,6 +132,17 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *models.AlertRu return nil, ErrNoPanel } + // If there is an image is in the cache return it instead of taking another screenshot + if image, ok := s.cache.Get(ctx, r.GetKey().String()); ok { + s.logger.Debug("Found cached image", "token", image.Token) + return &image, nil + } + + // We create both a context with timeout and set a timeout in ScreenshotOptions. The timeout + // in the context is used for both database queries and the request to the rendering service, + // while the timeout in ScreenshotOptions is passed to the rendering service where it is used as + // a client timeout. It is not recommended to pass a context without a deadline and the context + // deadline should be at least as long as the timeout in ScreenshotOptions. ctx, cancelFunc := context.WithTimeout(ctx, screenshotTimeout) defer cancelFunc() @@ -136,8 +152,11 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *models.AlertRu Timeout: screenshotTimeout, } + // To prevent concurrent screenshots of the same dashboard panel we use singleflight, + // deduplicated on a base64 hash of the screenshot options. optsHash := base64.StdEncoding.EncodeToString(opts.Hash()) result, err, _ := s.singleflight.Do(optsHash, func() (interface{}, error) { + // Once deduplicated concurrent screenshots are then rate-limited screenshot, err := s.limiter.Do(ctx, opts, s.screenshots.Take) if err != nil { if errors.Is(err, dashboards.ErrDashboardNotFound) { @@ -145,15 +164,20 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *models.AlertRu } return nil, err } + image := models.Image{Path: screenshot.Path} + + // Uploading images is optional if s.uploads != nil { if image, err = s.uploads.Upload(ctx, image); err != nil { - s.logger.Warn("failed to upload image", "path", image.Path, "error", err) + s.logger.Warn("Failed to upload image", "path", image.Path, "error", err) } } + if err := s.store.SaveImage(ctx, &image); err != nil { return nil, fmt.Errorf("failed to save image: %w", err) } + s.logger.Debug("Saved new image", "token", image.Token) return image, nil }) if err != nil { @@ -161,6 +185,10 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *models.AlertRu } image := result.(models.Image) + if err = s.cache.Set(ctx, r.GetKey().String(), image); err != nil { + s.logger.Warn("Failed to cache image", "token", image.Token, "error", err) + } + return &image, nil } diff --git a/pkg/services/ngalert/image/service_test.go b/pkg/services/ngalert/image/service_test.go index 62bd5eabd6a..57c0d1aeb78 100644 --- a/pkg/services/ngalert/image/service_test.go +++ b/pkg/services/ngalert/image/service_test.go @@ -23,17 +23,21 @@ func TestScreenshotImageService(t *testing.T) { defer ctrl.Finish() var ( + cache = NewMockCacheService(ctrl) images = store.NewFakeImageStore(t) limiter = screenshot.NoOpRateLimiter{} screenshots = screenshot.NewMockScreenshotService(ctrl) uploads = imguploader.NewMockImageUploader(ctrl) ) - s := NewScreenshotImageService(&limiter, log.NewNopLogger(), screenshots, images, + s := NewScreenshotImageService(cache, &limiter, log.NewNopLogger(), screenshots, images, NewUploadingService(uploads, prometheus.NewRegistry())) ctx := context.Background() + // assert that the cache is checked for an existing image + cache.EXPECT().Get(gomock.Any(), "{orgID: 1, UID: foo}").Return(models.Image{}, false) + // assert that a screenshot is taken screenshots.EXPECT().Take(gomock.Any(), screenshot.ScreenshotOptions{ DashboardUID: "foo", @@ -43,11 +47,11 @@ func TestScreenshotImageService(t *testing.T) { Path: "foo.png", }, nil) - // the screenshot is made into an image and uploaded + // assert that the screenshot is made into an image and uploaded uploads.EXPECT().Upload(gomock.Any(), "foo.png"). Return("https://example.com/foo.png", nil) - // and then saved into the database + // assert that the image is saved into the database expected := models.Image{ ID: 1, Token: "foo", @@ -55,12 +59,20 @@ func TestScreenshotImageService(t *testing.T) { URL: "https://example.com/foo.png", } + // assert that the image is saved into the cache + cache.EXPECT().Set(gomock.Any(), "{orgID: 1, UID: foo}", expected).Return(nil) + image, err := s.NewImage(ctx, &models.AlertRule{ + OrgID: 1, + UID: "foo", DashboardUID: pointer.String("foo"), PanelID: pointer.Int64(1)}) require.NoError(t, err) assert.Equal(t, expected, *image) + // assert that the cache is checked for an existing image + cache.EXPECT().Get(gomock.Any(), "{orgID: 1, UID: bar}").Return(models.Image{}, false) + // assert that a screenshot is taken screenshots.EXPECT().Take(gomock.Any(), screenshot.ScreenshotOptions{ DashboardUID: "bar", @@ -81,9 +93,27 @@ func TestScreenshotImageService(t *testing.T) { Path: "bar.png", } + // assert that the image is saved into the cache, but without a URL + cache.EXPECT().Set(gomock.Any(), "{orgID: 1, UID: bar}", expected).Return(nil) + image, err = s.NewImage(ctx, &models.AlertRule{ + OrgID: 1, + UID: "bar", DashboardUID: pointer.String("bar"), PanelID: pointer.Int64(1)}) require.NoError(t, err) assert.Equal(t, expected, *image) + + expected = models.Image{Path: "baz.png", URL: "https://example.com/baz.png"} + + // assert that the cache is checked for an existing image and it is returned + cache.EXPECT().Get(gomock.Any(), "{orgID: 1, UID: baz}").Return(expected, true) + + image, err = s.NewImage(ctx, &models.AlertRule{ + OrgID: 1, + UID: "baz", + DashboardUID: pointer.String("baz"), + PanelID: pointer.Int64(1)}) + require.NoError(t, err) + assert.Equal(t, expected, *image) } From 6ed35292fe9107b7dae7e8bcb4ec09859a9df3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 9 Nov 2022 08:02:24 +0100 Subject: [PATCH 143/926] Variables: SceneVariable update process (#57784) * First baby steps * First baby steps * No progress really * Updates * no progress * refactoring * Progress on sub menu and value selectors * Some more tweaks * Lots of progress * Progress * Updates * Progress * Tweaks * Updates * Updates to variable system * Cleaner tests * Update * Some cleanup * correct test name * Renames and moves * prop rename * Fixed scene template interpolator * More tests for SceneObjectBase and fixed issue in EventBus * Updates * More tweaks * More refinements * Fixed test * Added test to EventBus * Clone all scene object arrays * Simplify * tried to merge issue * Update * added more comments to interface * temp progress * Trying to simplify things, but struggling a bit * Updated * Tweaks * Progress on fixing the select componenet and typing, and sharing code in a base class * Updated * Multi select * Simpler loading state * Update * removed failOnConsole * Removed old funcs * Moved logic from update manage to MultiValueVariable * Added tests for MultiValueVariable logic * Made value a more abstract concept to support object values * renamed func to getValueText * Refactored and moved logic to VariableSet * Added test for deactivation and query cancelling * Tweaks * Fixed lint issues --- .../app/features/scenes/components/Scene.tsx | 12 +- .../scenes/components/SceneCanvasText.tsx | 5 +- .../scenes/components/SceneSubMenu.tsx | 22 +++ .../scenes/core/SceneObjectBase.test.ts | 2 +- .../features/scenes/core/SceneObjectBase.tsx | 4 +- public/app/features/scenes/core/types.ts | 1 + public/app/features/scenes/scenes/index.tsx | 3 +- .../features/scenes/scenes/variablesDemo.tsx | 63 +++++++ .../components/VariableValueSelect.tsx | 39 +++++ .../components/VariableValueSelectors.tsx | 85 ++++++++++ .../variables/getVariableDependencies.test.ts | 12 ++ .../variables/getVariableDependencies.ts | 20 +++ ...t.ts => sceneTemplateInterpolator.test.ts} | 18 +- ...bleSet.ts => sceneTemplateInterpolator.ts} | 24 ++- .../variables/sets/SceneVariableSet.test.ts | 95 +++++++++++ .../scenes/variables/sets/SceneVariableSet.ts | 157 ++++++++++++++++++ public/app/features/scenes/variables/types.ts | 48 +++++- .../variables/variants/ConstantVariable.ts | 15 ++ .../variants/MultiValueVariable.test.ts | 51 ++++++ .../variables/variants/MultiValueVariable.ts | 106 ++++++++++++ .../variables/variants/TestVariable.tsx | 79 +++++++++ .../variables/pickers/shared/VariableLink.tsx | 2 +- public/test/setupTests.ts | 1 + 23 files changed, 829 insertions(+), 35 deletions(-) create mode 100644 public/app/features/scenes/components/SceneSubMenu.tsx create mode 100644 public/app/features/scenes/scenes/variablesDemo.tsx create mode 100644 public/app/features/scenes/variables/components/VariableValueSelect.tsx create mode 100644 public/app/features/scenes/variables/components/VariableValueSelectors.tsx create mode 100644 public/app/features/scenes/variables/getVariableDependencies.test.ts create mode 100644 public/app/features/scenes/variables/getVariableDependencies.ts rename public/app/features/scenes/variables/{SceneVariableSet.test.ts => sceneTemplateInterpolator.test.ts} (68%) rename public/app/features/scenes/variables/{SceneVariableSet.ts => sceneTemplateInterpolator.ts} (59%) create mode 100644 public/app/features/scenes/variables/sets/SceneVariableSet.test.ts create mode 100644 public/app/features/scenes/variables/sets/SceneVariableSet.ts create mode 100644 public/app/features/scenes/variables/variants/ConstantVariable.ts create mode 100644 public/app/features/scenes/variables/variants/MultiValueVariable.test.ts create mode 100644 public/app/features/scenes/variables/variants/MultiValueVariable.ts create mode 100644 public/app/features/scenes/variables/variants/TestVariable.tsx diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx index 2848d2d2a8e..fe4731c13da 100644 --- a/public/app/features/scenes/components/Scene.tsx +++ b/public/app/features/scenes/components/Scene.tsx @@ -14,6 +14,7 @@ interface SceneState extends SceneObjectStatePlain { title: string; layout: SceneObject; actions?: SceneObject[]; + subMenu?: SceneObject; isEditing?: boolean; } @@ -33,7 +34,7 @@ export class Scene extends SceneObjectBase { } function SceneRenderer({ model }: SceneComponentProps) { - const { title, layout, actions = [], isEditing, $editor } = model.useState(); + const { title, layout, actions = [], isEditing, $editor, subMenu } = model.useState(); const toolbarActions = (actions ?? []).map((action) => ); @@ -55,9 +56,12 @@ function SceneRenderer({ model }: SceneComponentProps) { return ( -
- - {$editor && <$editor.Component model={$editor} isEditing={isEditing} />} +
+ {subMenu && } +
+ + {$editor && <$editor.Component model={$editor} isEditing={isEditing} />} +
); diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx index aee61d0dc85..93b5e9a74a8 100644 --- a/public/app/features/scenes/components/SceneCanvasText.tsx +++ b/public/app/features/scenes/components/SceneCanvasText.tsx @@ -4,6 +4,7 @@ import { Field, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; +import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator'; export interface SceneCanvasTextState extends SceneLayoutChildState { text: string; @@ -13,8 +14,10 @@ export interface SceneCanvasTextState extends SceneLayoutChildState { export class SceneCanvasText extends SceneObjectBase { public static Editor = Editor; + public static Component = ({ model }: SceneComponentProps) => { const { text, fontSize = 20, align = 'left' } = model.useState(); + const textInterpolated = sceneTemplateInterpolator(text, model); const style: CSSProperties = { fontSize: fontSize, @@ -25,7 +28,7 @@ export class SceneCanvasText extends SceneObjectBase { justifyContent: align, }; - return
{text}
; + return
{textInterpolated}
; }; } diff --git a/public/app/features/scenes/components/SceneSubMenu.tsx b/public/app/features/scenes/components/SceneSubMenu.tsx new file mode 100644 index 00000000000..2563c2f2a37 --- /dev/null +++ b/public/app/features/scenes/components/SceneSubMenu.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneLayoutState, SceneComponentProps } from '../core/types'; + +interface SceneSubMenuState extends SceneLayoutState {} + +export class SceneSubMenu extends SceneObjectBase { + public static Component = SceneSubMenuRenderer; +} + +function SceneSubMenuRenderer({ model }: SceneComponentProps) { + const { children } = model.useState(); + + return ( +
+ {children.map((child) => ( + + ))} +
+ ); +} diff --git a/public/app/features/scenes/core/SceneObjectBase.test.ts b/public/app/features/scenes/core/SceneObjectBase.test.ts index 17c86014163..c8d3f138e76 100644 --- a/public/app/features/scenes/core/SceneObjectBase.test.ts +++ b/public/app/features/scenes/core/SceneObjectBase.test.ts @@ -1,4 +1,4 @@ -import { SceneVariableSet } from '../variables/SceneVariableSet'; +import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; import { SceneDataNode } from './SceneDataNode'; import { SceneObjectBase } from './SceneObjectBase'; diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index d5871def18b..8c4e029e16d 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -9,7 +9,9 @@ import { SceneComponentWrapper } from './SceneComponentWrapper'; import { SceneObjectStateChangedEvent } from './events'; import { SceneDataState, SceneObject, SceneComponent, SceneEditor, SceneTimeRange, SceneObjectState } from './types'; -export abstract class SceneObjectBase implements SceneObject { +export abstract class SceneObjectBase + implements SceneObject +{ private _isActive = false; private _subject = new Subject(); private _state: TState; diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index 9791ba034e5..b531d6f95e6 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -114,6 +114,7 @@ export interface SceneEditor extends SceneObject { } export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {} + export interface SceneTimeRange extends SceneObject { onTimeRangeChange(timeRange: TimeRange): void; onIntervalChanged(interval: string): void; diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx index c28184ab78d..0a1db6b2830 100644 --- a/public/app/features/scenes/scenes/index.tsx +++ b/public/app/features/scenes/scenes/index.tsx @@ -3,9 +3,10 @@ import { Scene } from '../components/Scene'; import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo'; import { getNestedScene } from './nested'; import { getSceneWithRows } from './sceneWithRows'; +import { getVariablesDemo } from './variablesDemo'; export function getScenes(): Scene[] { - return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows()]; + return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows(), getVariablesDemo()]; } const cache: Record = {}; diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx new file mode 100644 index 00000000000..45ae1149d8c --- /dev/null +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -0,0 +1,63 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneCanvasText } from '../components/SceneCanvasText'; +import { SceneFlexLayout } from '../components/SceneFlexLayout'; +import { SceneSubMenu } from '../components/SceneSubMenu'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { VariableValueSelectors } from '../variables/components/VariableValueSelectors'; +import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; +import { TestVariable } from '../variables/variants/TestVariable'; + +export function getVariablesDemo(): Scene { + const scene = new Scene({ + title: 'Variables', + layout: new SceneFlexLayout({ + direction: 'row', + children: [ + new SceneCanvasText({ + text: 'Some text with a variable: ${server} - ${pod}', + fontSize: 40, + align: 'center', + }), + ], + }), + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'server', + query: 'A.*', + value: 'server', + text: '', + delayMs: 1000, + options: [], + }), + new TestVariable({ + name: 'pod', + query: 'A.$server.*', + value: 'pod', + delayMs: 1000, + text: '', + options: [], + }), + new TestVariable({ + name: 'handler', + query: 'A.$server.$pod.*', + value: 'handler', + delayMs: 1000, + isMulti: true, + text: '', + options: [], + }), + ], + }), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + actions: [new SceneTimePicker({})], + subMenu: new SceneSubMenu({ + children: [new VariableValueSelectors({})], + }), + }); + + return scene; +} diff --git a/public/app/features/scenes/variables/components/VariableValueSelect.tsx b/public/app/features/scenes/variables/components/VariableValueSelect.tsx new file mode 100644 index 00000000000..b70dcc28920 --- /dev/null +++ b/public/app/features/scenes/variables/components/VariableValueSelect.tsx @@ -0,0 +1,39 @@ +import { isArray } from 'lodash'; +import React from 'react'; + +import { Select, MultiSelect } from '@grafana/ui'; + +import { SceneComponentProps } from '../../core/types'; +import { MultiValueVariable } from '../variants/MultiValueVariable'; + +export function VariableValueSelect({ model }: SceneComponentProps) { + const { value, key, loading, isMulti, options } = model.useState(); + + if (isMulti) { + return ( + + ); + } + + return ( + { + setState({ isLoadingLabelNames: true }); + const labelNames = await onGetLabelNames(item); + setState({ labelNames, isLoadingLabelNames: undefined }); + }} + isLoading={state.isLoadingLabelNames ?? false} + options={state.labelNames} + onChange={(change) => { + if (change.label) { + onChange({ + ...item, + op: item.op ?? defaultOp, + label: change.label, + // eslint-ignore + } as QueryBuilderLabelFilter); + } + }} + invalid={invalidLabel} + /> + + {/* Operator select i.e. = =~ != !~ */} + remove(index)} - variant="destructive" - className={styles.destroyInputRow} - > - - - } - /> - - ); - })} - -
- )} - -
- -
-
- )} - - - ); -}; - -function cleanAlertmanagerUrl(url: string): string { - return url.replace(/\/$/, '').replace(/\/api\/v[1|2]\/alerts/i, ''); -} - -const getStyles = (theme: GrafanaTheme2) => { - const muted = css` - color: ${theme.colors.text.secondary}; - `; - return { - description: cx( - css` - margin-bottom: ${theme.spacing(2)}; - `, - muted - ), - muted: muted, - bold: css` - font-weight: ${theme.typography.fontWeightBold}; - `, - modal: css``, - modalIcon: cx( - muted, - css` - margin-right: ${theme.spacing(1)}; - ` - ), - modalTitle: css` - display: flex; - `, - input: css` - margin-bottom: ${theme.spacing(1)}; - margin-right: ${theme.spacing(1)}; - `, - inputRow: css` - display: flex; - `, - destroyInputRow: css` - padding: ${theme.spacing(1)}; - `, - fieldArray: css` - margin-bottom: ${theme.spacing(4)}; - `, - }; -}; diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx index 633e1519044..9a3c812f80d 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx @@ -18,7 +18,7 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext return ( <> -
Alertmanagers data sources
+
Alertmanagers Receiving Grafana-managed alerts
Alertmanager data sources support a configuration setting that allows you to choose to send Grafana-managed alerts to that Alertmanager.
@@ -102,6 +102,8 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd export const getStyles = (theme: GrafanaTheme2) => ({ muted: css` + font-size: ${theme.typography.bodySmall.fontSize}; + line-height: ${theme.typography.bodySmall.lineHeight}; color: ${theme.colors.text.secondary}; `, externalHeading: css` diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx index 80080718c40..1bfb98fe018 100644 --- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx @@ -1,28 +1,15 @@ -import { css, cx } from '@emotion/css'; -import React, { useCallback, useEffect, useState } from 'react'; +import { css } from '@emotion/css'; +import React, { useEffect } from 'react'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { - Alert, - Button, - ConfirmModal, - Field, - HorizontalGroup, - Icon, - RadioButtonGroup, - Tooltip, - useStyles2, - useTheme2, -} from '@grafana/ui'; -import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { Alert, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { loadDataSources } from 'app/features/datasources/state/actions'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; import { alertmanagerApi } from '../../api/alertmanagerApi'; -import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector'; +import { useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector'; -import { AddAlertManagerModal } from './AddAlertManagerModal'; import { ExternalAlertmanagerDataSources } from './ExternalAlertmanagerDataSources'; const alertmanagerChoices: Array> = [ @@ -34,10 +21,7 @@ const alertmanagerChoices: Array> = [ export const ExternalAlertmanagers = () => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); - const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] }); - const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 }); - const externalAlertManagers = useExternalAmSelector(); const externalDsAlertManagers = useExternalDataSourceAlertmanagers(); const { @@ -53,84 +37,15 @@ export const ExternalAlertmanagers = () => { useGetExternalAlertmanagersQuery(undefined, { pollingInterval: 5000 }); const alertmanagersChoice = externalAlertmanagerConfig?.alertmanagersChoice; - const theme = useTheme2(); useEffect(() => { dispatch(loadDataSources()); }, [dispatch]); - const onDelete = useCallback( - (index: number) => { - // to delete we need to filter the alertmanager from the list and repost - const newList = (externalAlertManagers ?? []) - .filter((am, i) => i !== index) - .map((am) => { - return am.url; - }); - - saveExternalAlertManagers({ - alertmanagers: newList, - alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All, - }); - - setDeleteModalState({ open: false, index: 0 }); - }, - [externalAlertManagers, saveExternalAlertManagers, alertmanagersChoice] - ); - - const onEdit = useCallback(() => { - const ams = externalAlertManagers ? [...externalAlertManagers] : [{ url: '' }]; - setModalState((state) => ({ - ...state, - open: true, - payload: ams, - })); - }, [setModalState, externalAlertManagers]); - - const onOpenModal = useCallback(() => { - setModalState((state) => { - const ams = externalAlertManagers ? [...externalAlertManagers, { url: '' }] : [{ url: '' }]; - return { - ...state, - open: true, - payload: ams, - }; - }); - }, [externalAlertManagers]); - - const onCloseModal = useCallback(() => { - setModalState((state) => ({ - ...state, - open: false, - })); - }, [setModalState]); - const onChangeAlertmanagerChoice = (alertmanagersChoice: AlertmanagerChoice) => { - saveExternalAlertManagers({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice }); + saveExternalAlertManagers({ alertmanagersChoice }); }; - const onChangeAlertmanagers = (alertmanagers: string[]) => { - saveExternalAlertManagers({ - alertmanagers, - alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All, - }); - }; - - const getStatusColor = (status: string) => { - switch (status) { - case 'active': - return theme.colors.success.main; - - case 'pending': - return theme.colors.warning.main; - - default: - return theme.colors.error.main; - } - }; - - const noAlertmanagers = externalAlertManagers?.length === 0; - return (

External Alertmanagers

@@ -142,15 +57,10 @@ export const ExternalAlertmanagers = () => { For more information, refer to our documentation. - -
{
-
Alertmanagers by URL
- - The URL-based configuration of Alertmanagers is deprecated and will be removed in Grafana 9.2.0. -
- Use Alertmanager data sources to configure your external Alertmanagers. -
- -
- You can have your Grafana managed alerts be delivered to one or many external Alertmanager(s) in addition to the - internal Alertmanager by specifying their URLs below. -
-
- {!noAlertmanagers && ( - - )} -
- - {noAlertmanagers ? ( - - ) : ( - <> -
- - - - - - - - - {externalAlertManagers?.map((am, index) => { - return ( - - - - - - ); - })} - -
UrlStatusAction
- {am.url} - {am.actualUrl ? ( - - - - ) : null} - - - - - - - -
- - )} - - onDelete(deleteModalState.index)} - onDismiss={() => setDeleteModalState({ open: false, index: 0 })} + - {modalState.open && ( - - )}
); }; @@ -257,9 +82,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({ url: css` margin-right: ${theme.spacing(1)}; `, - muted: css` - color: ${theme.colors.text.secondary}; - `, actions: css` margin-top: ${theme.spacing(2)}; display: flex; diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx index 3bdbdd5ac05..9c0d44d652c 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx +++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx @@ -8,12 +8,12 @@ import 'whatwg-fetch'; import { DataSourceJsonData, DataSourceSettings } from '@grafana/data'; import { config } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; -import { AlertmanagerChoice, AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; +import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { mockDataSource, mockDataSourcesStore, mockStore } from '../mocks'; -import { mockAlertmanagerConfigResponse, mockAlertmanagersResponse } from '../mocks/alertmanagerApi'; +import { mockAlertmanagersResponse } from '../mocks/alertmanagerApi'; -import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from './useExternalAmSelector'; +import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector'; const server = setupServer(); @@ -34,184 +34,6 @@ afterAll(() => { server.close(); }); -describe('useExternalAmSelector', () => { - it('should have one in pending', async () => { - mockAlertmanagersResponse(server, { - data: { - activeAlertManagers: [], - droppedAlertManagers: [], - }, - }); - mockAlertmanagerConfigResponse(server, { - alertmanagers: ['some/url/to/am'], - alertmanagersChoice: AlertmanagerChoice.All, - }); - const store = mockStore(() => null); - - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper }); - await waitFor(() => result.current.length > 0); - - const { current: alertmanagers } = result; - - expect(alertmanagers).toEqual([ - { - url: 'some/url/to/am', - status: 'pending', - actualUrl: '', - }, - ]); - }); - - it('should have one active, one pending', async () => { - mockAlertmanagersResponse(server, { - data: { - activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }], - droppedAlertManagers: [], - }, - }); - mockAlertmanagerConfigResponse(server, { - alertmanagers: ['some/url/to/am', 'some/url/to/am1'], - alertmanagersChoice: AlertmanagerChoice.All, - }); - const store = mockStore(() => null); - - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper }); - await waitFor(() => result.current.length > 0); - - const { current: alertmanagers } = result; - - expect(alertmanagers).toEqual([ - { - url: 'some/url/to/am', - actualUrl: 'some/url/to/am/api/v2/alerts', - status: 'active', - }, - { - url: 'some/url/to/am1', - actualUrl: '', - status: 'pending', - }, - ]); - }); - - it('should have two active', async () => { - mockAlertmanagersResponse(server, { - data: { - activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }, { url: 'some/url/to/am1/api/v2/alerts' }], - droppedAlertManagers: [], - }, - }); - mockAlertmanagerConfigResponse(server, { - alertmanagers: ['some/url/to/am', 'some/url/to/am1'], - alertmanagersChoice: AlertmanagerChoice.All, - }); - const store = mockStore(() => null); - - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper }); - await waitFor(() => result.current.length > 0); - - const { current: alertmanagers } = result; - - expect(alertmanagers).toEqual([ - { - url: 'some/url/to/am', - actualUrl: 'some/url/to/am/api/v2/alerts', - status: 'active', - }, - { - url: 'some/url/to/am1', - actualUrl: 'some/url/to/am1/api/v2/alerts', - status: 'active', - }, - ]); - }); - - it('should have one active, one dropped, one pending', async () => { - mockAlertmanagersResponse(server, { - data: { - activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }], - droppedAlertManagers: [{ url: 'some/dropped/url/api/v2/alerts' }], - }, - }); - mockAlertmanagerConfigResponse(server, { - alertmanagers: ['some/url/to/am', 'some/url/to/am1'], - alertmanagersChoice: AlertmanagerChoice.All, - }); - const store = mockStore(() => null); - - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper }); - await waitFor(() => result.current.length > 0); - - const { current: alertmanagers } = result; - expect(alertmanagers).toEqual([ - { - url: 'some/url/to/am', - actualUrl: 'some/url/to/am/api/v2/alerts', - status: 'active', - }, - { - url: 'some/url/to/am1', - actualUrl: '', - status: 'pending', - }, - { - url: 'some/dropped/url', - actualUrl: 'some/dropped/url/api/v2/alerts', - status: 'dropped', - }, - ]); - }); - - it('The number of alert managers should match config entries when there are multiple entries of the same url', async () => { - mockAlertmanagersResponse(server, { - data: { - activeAlertManagers: [ - { url: 'same/url/to/am/api/v2/alerts' }, - { url: 'same/url/to/am/api/v2/alerts' }, - { url: 'same/url/to/am/api/v2/alerts' }, - ], - droppedAlertManagers: [], - }, - }); - mockAlertmanagerConfigResponse(server, { - alertmanagers: ['same/url/to/am', 'same/url/to/am', 'same/url/to/am'], - alertmanagersChoice: AlertmanagerChoice.All, - }); - const store = mockStore(() => null); - - const wrapper = ({ children }: React.PropsWithChildren<{}>) => {children}; - - const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper }); - await waitFor(() => result.current.length > 0); - - const { current: alertmanagers } = result; - - expect(alertmanagers.length).toBe(3); - expect(alertmanagers).toEqual([ - { - url: 'same/url/to/am', - actualUrl: 'same/url/to/am/api/v2/alerts', - status: 'active', - }, - { - url: 'same/url/to/am', - actualUrl: 'same/url/to/am/api/v2/alerts', - status: 'active', - }, - { - url: 'same/url/to/am', - actualUrl: 'same/url/to/am/api/v2/alerts', - status: 'active', - }, - ]); - }); -}); - describe('useExternalDataSourceAlertmanagers', () => { it('Should merge data sources information from config and api responses', async () => { // Arrange diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts index aeb81d6aa9e..65f20eb0384 100644 --- a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts +++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts @@ -7,54 +7,6 @@ import { useSelector } from 'app/types'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { getAlertManagerDataSources } from '../utils/datasource'; -const SUFFIX_REGEX = /\/api\/v[1|2]\/alerts/i; -type AlertmanagerConfig = { url: string; status: string; actualUrl: string }; - -export function useExternalAmSelector(): AlertmanagerConfig[] | [] { - const { useGetExternalAlertmanagersQuery, useGetExternalAlertmanagerConfigQuery } = alertmanagerApi; - - const { currentData: discoveredAlertmanagers } = useGetExternalAlertmanagersQuery(); - const { currentData: alertmanagerConfig } = useGetExternalAlertmanagerConfigQuery(); - - if (!discoveredAlertmanagers || !alertmanagerConfig) { - return []; - } - - const enabledAlertmanagers: AlertmanagerConfig[] = []; - const droppedAlertmanagers: AlertmanagerConfig[] = discoveredAlertmanagers.droppedAlertManagers.map((am) => ({ - url: am.url.replace(SUFFIX_REGEX, ''), - status: 'dropped', - actualUrl: am.url, - })); - - for (const url of alertmanagerConfig.alertmanagers) { - if (discoveredAlertmanagers.activeAlertManagers.length === 0) { - enabledAlertmanagers.push({ - url: url, - status: 'pending', - actualUrl: '', - }); - } else { - const matchingActiveAM = discoveredAlertmanagers.activeAlertManagers.find( - (am) => am.url === `${url}/api/v2/alerts` - ); - matchingActiveAM - ? enabledAlertmanagers.push({ - url: matchingActiveAM.url.replace(SUFFIX_REGEX, ''), - status: 'active', - actualUrl: matchingActiveAM.url, - }) - : enabledAlertmanagers.push({ - url: url, - status: 'pending', - actualUrl: '', - }); - } - } - - return [...enabledAlertmanagers, ...droppedAlertmanagers]; -} - export interface ExternalDataSourceAM { dataSource: DataSourceInstanceSettings; url?: string; diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts index 36e040966ab..48188bedb4c 100644 --- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts @@ -1,10 +1,7 @@ import { rest } from 'msw'; import { SetupServerApi } from 'msw/node'; -import { - ExternalAlertmanagerConfig, - ExternalAlertmanagersResponse, -} from '../../../../plugins/datasource/alertmanager/types'; +import { ExternalAlertmanagersResponse } from '../../../../plugins/datasource/alertmanager/types'; import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi'; export function mockAlertmanagerChoiceResponse(server: SetupServerApi, respose: AlertmanagersChoiceResponse) { @@ -14,7 +11,3 @@ export function mockAlertmanagerChoiceResponse(server: SetupServerApi, respose: export function mockAlertmanagersResponse(server: SetupServerApi, response: ExternalAlertmanagersResponse) { server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); } - -export function mockAlertmanagerConfigResponse(server: SetupServerApi, response: ExternalAlertmanagerConfig) { - server.use(rest.get('/api/v1/ngalert/admin_config', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); -} diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 3ca22d729bc..51457c8a156 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -286,7 +286,6 @@ export enum AlertmanagerChoice { } export interface ExternalAlertmanagerConfig { - alertmanagers: string[]; alertmanagersChoice: AlertmanagerChoice; } From 261d620f1c46eb43282cc444ffb4633b77af4283 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 10 Nov 2022 16:35:15 +0100 Subject: [PATCH 184/926] Elasticsearch: Add feature toggle for backend migration (#58585) * Elasticsearch: Add feature toggle for backend migration * Update --- packages/grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 5 +++++ pkg/services/featuremgmt/toggles_gen.go | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c4334575773..b38759798f8 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -80,4 +80,5 @@ export interface FeatureToggles { datasourceLogger?: boolean; accessControlOnCall?: boolean; nestedFolders?: boolean; + elasticsearchBackendMigration?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 230fd34f893..dbd3223d1f0 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -357,5 +357,10 @@ var ( State: FeatureStateAlpha, RequiresDevMode: true, }, + { + Name: "elasticsearchBackendMigration", + Description: "Use Elasticsearch as backend data source", + State: FeatureStateAlpha, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 432aac5a880..6a952ba0e75 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -262,4 +262,8 @@ const ( // FlagNestedFolders // Enable folder nesting FlagNestedFolders = "nestedFolders" + + // FlagElasticsearchBackendMigration + // Use Elasticsearch as backend data source + FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" ) From 9cbbe652438113b1d277f121be02604cc2360252 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 10 Nov 2022 17:02:41 +0000 Subject: [PATCH 185/926] Move cloud link app to the Administration section (#58578) --- pkg/services/navtree/navtreeimpl/applinks.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 35d4230133d..f103b336525 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -233,6 +233,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1}, "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3}, + "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, } s.navigationAppPathConfig = map[string]NavigationAppConfig{ From 5bc7f693b556aa3e39907c500c1faa91bf04ede0 Mon Sep 17 00:00:00 2001 From: Timur Olzhabayev Date: Thu, 10 Nov 2022 18:31:42 +0100 Subject: [PATCH 186/926] Docs: Replacing toolkit with sign-plugin (#58593) --- docs/sources/developers/plugins/sign-a-plugin.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sources/developers/plugins/sign-a-plugin.md b/docs/sources/developers/plugins/sign-a-plugin.md index 89ece6e5c66..4d5730a1d86 100644 --- a/docs/sources/developers/plugins/sign-a-plugin.md +++ b/docs/sources/developers/plugins/sign-a-plugin.md @@ -36,24 +36,24 @@ Public plugins need to be reviewed by the Grafana team before you can sign them. 1. Submit your plugin for [review]({{< relref "package-a-plugin/#publishing-your-plugin-for-the-first-time" >}}) 2. When your plugin is approved, you're granted a plugin signature level. **Without a plugin signature level, you won't be able to sign your plugin**. -3. In your plugin directory, sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. +3. In your plugin directory, sign the plugin with the API key you just created. Grafana Sign Plugin creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. ```bash export GRAFANA_API_KEY= - npx @grafana/toolkit plugin:sign + npx @grafana/sign-plugin plugin:sign ``` > **Note:** If running NPM 7+ the `npx` commands mentioned in this article may hang. The workaround is to use `npx --legacy-peer-deps `. ## Sign a private plugin -1. In your plugin directory, sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. +1. In your plugin directory, sign the plugin with the API key you just created. Grafana Sign Plugin creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin. The `rootUrls` flag accepts a comma-separated list of URLs to the Grafana instances where you intend to install the plugin. ```bash export GRAFANA_API_KEY= - npx @grafana/toolkit plugin:sign --rootUrls https://example.com/grafana + npx @grafana/sign-plugin plugin:sign --rootUrls https://example.com/grafana ``` ## Plugin signature levels @@ -119,7 +119,7 @@ T6scfmuhWC/TOcm83EVoCzIV3R5dOTKHqkjIUg== ### Why am I getting a "Modified signature" in Grafana? -Due to an issue when signing the plugin on Windows, grafana-toolkit generates an invalid MANIFEST.txt. You can fix this by replacing all double backslashes, `\\`, with a forward slash, `/` in the MANIFEST.txt file. You need to do this every time you sign your plugin. +Due to an issue when signing the plugin on Windows, in some cases an invalid MANIFEST.txt is being generated. You can fix this by replacing all double backslashes, `\\`, with a forward slash, `/` in the MANIFEST.txt file. You need to do this every time you sign your plugin. ### Error signing manifest: Field is required: rootUrls From 41c491e2db9aade8bb17e9ace25a8d682a5223e6 Mon Sep 17 00:00:00 2001 From: Timur Olzhabayev Date: Thu, 10 Nov 2022 18:36:59 +0100 Subject: [PATCH 187/926] Cleaning up plugin developer docs (#58596) --- docs/sources/developers/plugins/_index.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/sources/developers/plugins/_index.md b/docs/sources/developers/plugins/_index.md index 8f1a99cf09c..876d16c0b6d 100644 --- a/docs/sources/developers/plugins/_index.md +++ b/docs/sources/developers/plugins/_index.md @@ -73,20 +73,10 @@ Explore the many UI components in our [Grafana UI library](https://developers.gr For inspiration, check out our [plugin examples](https://github.com/grafana/grafana-plugin-examples). -### API reference - -Learn more about Grafana options and packages. - -#### Metadata +### Metadata - [Plugin metadata]({{< relref "metadata/" >}}) -#### Typescript - -- Grafana Data -- Grafana Runtime -- Grafana UI - -#### Go +### SDK - [Grafana Plugin SDK for Go]({{< relref "backend/grafana-plugin-sdk-for-go/" >}}) From 47055561ec92ddb6f4d1d1fdcedb09345cb3b0cf Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 10 Nov 2022 10:56:31 -0800 Subject: [PATCH 188/926] Canvas: Fix setting icon from field data #58499 --- .betterer.results | 6 ------ public/app/features/dimensions/resource.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.betterer.results b/.betterer.results index 647193c730c..34cafb51e18 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3870,12 +3870,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/dimensions/resource.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], "public/app/features/dimensions/scale.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/dimensions/resource.ts b/public/app/features/dimensions/resource.ts index d48873a5740..523a044f666 100644 --- a/public/app/features/dimensions/resource.ts +++ b/public/app/features/dimensions/resource.ts @@ -10,7 +10,7 @@ export function getPublicOrAbsoluteUrl(v: string): string { if (!v) { return ''; } - return v.indexOf(':/') > 0 ? v : (window as any).__grafana_public_path__ + v; + return v.indexOf(':/') > 0 ? v : window.__grafana_public_path__ + v; } export function getResourceDimension( @@ -19,7 +19,7 @@ export function getResourceDimension( ): DimensionSupplier { const mode = config.mode ?? ResourceDimensionMode.Fixed; if (mode === ResourceDimensionMode.Fixed) { - const v = getPublicOrAbsoluteUrl(config.fixed!); + const v = getPublicOrAbsoluteUrl(config.fixed); return { isAssumed: !Boolean(v), fixed: v, @@ -40,7 +40,7 @@ export function getResourceDimension( } if (mode === ResourceDimensionMode.Mapping) { - const mapper = (v: any) => getPublicOrAbsoluteUrl(`${v}`); + const mapper = (v: string) => getPublicOrAbsoluteUrl(`${v}`); return { field, get: (i) => mapper(field.values.get(i)), @@ -48,9 +48,10 @@ export function getResourceDimension( }; } - const getIcon = (value: any): string => { - const disp = field.display!; - return getPublicOrAbsoluteUrl(disp(value).icon ?? ''); + // mode === ResourceDimensionMode.Field case + const getIcon = (value: string): string => { + const display = field.display!; + return getPublicOrAbsoluteUrl(display(value).icon ?? value ?? ''); }; return { From f92d978386377a3b0f00c835f135cb5a5cca98bd Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 10 Nov 2022 11:16:31 -0800 Subject: [PATCH 189/926] Export: support export in postgresql (#58553) --- pkg/services/export/commit_helper.go | 39 +++++++++++++++---- pkg/services/export/export_auth.go | 16 ++++---- pkg/services/export/export_dash.go | 5 ++- pkg/services/export/export_dash_thumbs.go | 2 +- pkg/services/export/export_live.go | 3 +- pkg/services/export/export_plugins.go | 3 +- pkg/services/export/export_sys_playlists.go | 9 ++++- pkg/services/export/export_usage.go | 3 +- pkg/services/export/git_export_job.go | 16 ++++---- pkg/services/export/object_store.go | 18 +++++---- pkg/services/export/service.go | 7 ++-- pkg/services/export/utils.go | 29 ++++++++++++++ .../object/sqlstash/sql_storage_server.go | 4 ++ 13 files changed, 109 insertions(+), 45 deletions(-) create mode 100644 pkg/services/export/utils.go diff --git a/pkg/services/export/commit_helper.go b/pkg/services/export/commit_helper.go index 7cdd4ef992c..221912c7738 100644 --- a/pkg/services/export/commit_helper.go +++ b/pkg/services/export/commit_helper.go @@ -14,6 +14,8 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/user" ) type commitHelper struct { @@ -44,13 +46,17 @@ type commitOptions struct { comment string } -func (ch *commitHelper) initOrg(sql db.DB, orgID int64) error { +func (ch *commitHelper) initOrg(ctx context.Context, sql db.DB, orgID int64) error { return sql.WithDbSession(ch.ctx, func(sess *db.Session) error { + userprefix := "user" + if isPostgreSQL(sql) { + userprefix = `"user"` // postgres has special needs + } sess.Table("user"). - Join("inner", "org_user", "user.id = org_user.user_id"). - Cols("user.*", "org_user.role"). + Join("inner", "org_user", userprefix+`.id = org_user.user_id`). + Cols(userprefix+`.*`, "org_user.role"). Where("org_user.org_id = ?", orgID). - Asc("user.id") + Asc(userprefix + `.id`) rows := make([]*userInfo, 0) err := sess.Find(&rows) @@ -64,6 +70,14 @@ func (ch *commitHelper) initOrg(sql db.DB, orgID int64) error { } ch.users = lookup ch.orgID = orgID + + // Set an admin user with the + rowUser := &user.SignedInUser{ + Login: "", + OrgID: orgID, // gets filled in from each row + UserID: 0, + } + ch.ctx = store.ContextWithUser(context.Background(), rowUser) return err }) } @@ -140,19 +154,28 @@ func (ch *commitHelper) add(opts commitOptions) error { } type userInfo struct { - ID int64 `json:"-" xorm:"id"` + ID int64 `json:"-" db:"id"` Login string `json:"login"` Email string `json:"email"` Name string `json:"name"` Password string `json:"password"` Salt string `json:"salt"` + Company string `json:"company,omitempty"` + Rands string `json:"-"` Role string `json:"org_role"` // org role Theme string `json:"-"` // managed in preferences Created time.Time `json:"-"` // managed in git or external source Updated time.Time `json:"-"` // managed in git or external source - IsDisabled bool `json:"disabled" xorm:"is_disabled"` - IsServiceAccount bool `json:"serviceAccount" xorm:"is_service_account"` - LastSeenAt time.Time `json:"-" xorm:"last_seen_at"` + IsDisabled bool `json:"disabled" db:"is_disabled"` + IsServiceAccount bool `json:"serviceAccount" db:"is_service_account"` + LastSeenAt time.Time `json:"-" db:"last_seen_at"` + + // Added to make sqlx happy + Version int `json:"-"` + HelpFlags1 int `json:"-" db:"help_flags1"` + OrgID int64 `json:"-" db:"org_id"` + EmailVerified bool `json:"-" db:"email_verified"` + IsAdmin bool `json:"-" db:"is_admin"` } func (u *userInfo) getAuthor() object.Signature { diff --git a/pkg/services/export/export_auth.go b/pkg/services/export/export_auth.go index 18b324efe0b..d3315a6599c 100644 --- a/pkg/services/export/export_auth.go +++ b/pkg/services/export/export_auth.go @@ -3,7 +3,6 @@ package export import ( "path" "strconv" - "strings" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" @@ -12,6 +11,8 @@ import ( ) func dumpAuthTables(helper *commitHelper, job *gitExportJob) error { + isMySQL := isMySQLEngine(job.sql) + return job.sql.WithDbSession(helper.ctx, func(sess *db.Session) error { commit := commitOptions{ comment: "auth tables dump", @@ -27,11 +28,11 @@ func dumpAuthTables(helper *commitHelper, job *gitExportJob) error { dump := []statsTables{ { table: "user", - sql: ` - SELECT user.*, org_user.role - FROM user - JOIN org_user ON user.id = org_user.user_id - WHERE org_user.org_id =` + strconv.FormatInt(helper.orgID, 10), + sql: removeQuotesFromQuery(` + SELECT "user".*, org_user.role + FROM "user" + JOIN org_user ON "user".id = org_user.user_id + WHERE org_user.org_id =`+strconv.FormatInt(helper.orgID, 10), isMySQL), converters: []sqlutil.Converter{{Dynamic: true}}, drop: []string{ "id", "version", @@ -74,7 +75,6 @@ func dumpAuthTables(helper *commitHelper, job *gitExportJob) error { WHERE org_user.org_id =` + strconv.FormatInt(helper.orgID, 10), }, {table: "team"}, - {table: "team_group"}, {table: "team_role"}, {table: "team_member"}, {table: "temp_user"}, @@ -99,7 +99,7 @@ func dumpAuthTables(helper *commitHelper, job *gitExportJob) error { rows, err := sess.DB().QueryContext(helper.ctx, auth.sql) if err != nil { - if strings.HasPrefix(err.Error(), "no such table") { + if isTableNotExistsError(err) { continue } return err diff --git a/pkg/services/export/export_dash.go b/pkg/services/export/export_dash.go index 59dec7a0c80..574c83d046f 100644 --- a/pkg/services/export/export_dash.go +++ b/pkg/services/export/export_dash.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/filestorage" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/store/kind/dashboard" ) @@ -30,7 +31,7 @@ func exportDashboards(helper *commitHelper, job *gitExportJob) error { return err } - rootDir := path.Join(helper.orgDir, "root") + rootDir := path.Join(helper.orgDir, models.ObjectStoreScopeDrive) folderStructure := commitOptions{ when: time.Now(), comment: "Exported folder structure", @@ -95,7 +96,7 @@ func exportDashboards(helper *commitHelper, job *gitExportJob) error { if row.IsFolder { continue } - fname := row.Slug + "-dash.json" + fname := row.Slug + "-dashboard.json" fpath, ok := folders[row.FolderID] if ok { fpath = path.Join(fpath, fname) diff --git a/pkg/services/export/export_dash_thumbs.go b/pkg/services/export/export_dash_thumbs.go index 22e0fdbaf2a..fa90dd8bc07 100644 --- a/pkg/services/export/export_dash_thumbs.go +++ b/pkg/services/export/export_dash_thumbs.go @@ -46,7 +46,7 @@ func exportDashboardThumbnails(helper *commitHelper, job *gitExportJob) error { err := sess.Find(&rows) if err != nil { - if strings.HasPrefix(err.Error(), "no such table") { + if isTableNotExistsError(err) { return nil } return err diff --git a/pkg/services/export/export_live.go b/pkg/services/export/export_live.go index 4564203475f..c8607353ab0 100644 --- a/pkg/services/export/export_live.go +++ b/pkg/services/export/export_live.go @@ -3,7 +3,6 @@ package export import ( "fmt" "path" - "strings" "time" "github.com/grafana/grafana/pkg/infra/db" @@ -26,7 +25,7 @@ func exportLive(helper *commitHelper, job *gitExportJob) error { err := sess.Find(&rows) if err != nil { - if strings.HasPrefix(err.Error(), "no such table") { + if isTableNotExistsError(err) { return nil } return err diff --git a/pkg/services/export/export_plugins.go b/pkg/services/export/export_plugins.go index 95398de0be2..37dde5ec378 100644 --- a/pkg/services/export/export_plugins.go +++ b/pkg/services/export/export_plugins.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "path" - "strings" "time" "github.com/grafana/grafana/pkg/infra/db" @@ -29,7 +28,7 @@ func exportPlugins(helper *commitHelper, job *gitExportJob) error { err := sess.Find(&rows) if err != nil { - if strings.HasPrefix(err.Error(), "no such table") { + if isTableNotExistsError(err) { return nil } return err diff --git a/pkg/services/export/export_sys_playlists.go b/pkg/services/export/export_sys_playlists.go index 97578dadc83..5faf70414aa 100644 --- a/pkg/services/export/export_sys_playlists.go +++ b/pkg/services/export/export_sys_playlists.go @@ -5,6 +5,7 @@ import ( "path/filepath" "time" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/playlist" ) @@ -37,8 +38,12 @@ func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error { } gitcmd.body = append(gitcmd.body, commitBody{ - fpath: filepath.Join(helper.orgDir, "system", "playlists", fmt.Sprintf("%s-playlist.json", playlist.Uid)), - body: prettyJSON(playlist), + fpath: filepath.Join( + helper.orgDir, + models.ObjectStoreScopeEntity, + models.StandardKindPlaylist, + fmt.Sprintf("%s.json", playlist.Uid)), + body: prettyJSON(playlist), }) } diff --git a/pkg/services/export/export_usage.go b/pkg/services/export/export_usage.go index 2e7dc2c9da2..85c16e98552 100644 --- a/pkg/services/export/export_usage.go +++ b/pkg/services/export/export_usage.go @@ -3,7 +3,6 @@ package export import ( "path" "strconv" - "strings" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" @@ -64,7 +63,7 @@ func exportUsage(helper *commitHelper, job *gitExportJob) error { for _, usage := range dump { rows, err := sess.DB().QueryContext(helper.ctx, usage.sql) if err != nil { - if strings.HasPrefix(err.Error(), "no such table") { + if isTableNotExistsError(err) { continue } return err diff --git a/pkg/services/export/git_export_job.go b/pkg/services/export/git_export_job.go index e4ba735ff21..ab8822ae91c 100644 --- a/pkg/services/export/git_export_job.go +++ b/pkg/services/export/git_export_job.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "path" + "runtime/debug" "sync" "time" @@ -37,7 +38,7 @@ type gitExportJob struct { helper *commitHelper } -func startGitExportJob(cfg ExportConfig, sql db.DB, +func startGitExportJob(ctx context.Context, cfg ExportConfig, sql db.DB, dashboardsnapshotsService dashboardsnapshots.Service, rootDir string, orgID int64, broadcaster statusBroadcaster, playlistService playlist.Service, orgService org.Service, datasourceService datasources.DataSourceService) (Job, error) { @@ -60,7 +61,7 @@ func startGitExportJob(cfg ExportConfig, sql db.DB, } broadcaster(job.status) - go job.start() + go job.start(ctx) return job, nil } @@ -83,7 +84,7 @@ func (e *gitExportJob) requestStop() { } // Utility function to export dashboards -func (e *gitExportJob) start() { +func (e *gitExportJob) start(ctx context.Context) { defer func() { e.logger.Info("Finished git export job") e.statusMu.Lock() @@ -91,6 +92,7 @@ func (e *gitExportJob) start() { s := e.status if err := recover(); err != nil { e.logger.Error("export panic", "error", err) + e.logger.Error("trace", "error", string(debug.Stack())) s.Status = fmt.Sprintf("ERROR: %v", err) } // Make sure it finishes OK @@ -106,7 +108,7 @@ func (e *gitExportJob) start() { e.broadcaster(s) }() - err := e.doExportWithHistory() + err := e.doExportWithHistory(ctx) if err != nil { e.logger.Error("ERROR", "e", err) e.status.Status = "ERROR" @@ -115,7 +117,7 @@ func (e *gitExportJob) start() { } } -func (e *gitExportJob) doExportWithHistory() error { +func (e *gitExportJob) doExportWithHistory(ctx context.Context) error { r, err := git.PlainInit(e.rootDir, false) if err != nil { return err @@ -134,7 +136,7 @@ func (e *gitExportJob) doExportWithHistory() error { e.helper = &commitHelper{ repo: r, work: w, - ctx: context.Background(), + ctx: ctx, workDir: e.rootDir, orgDir: e.rootDir, broadcast: func(p string) { @@ -157,7 +159,7 @@ func (e *gitExportJob) doExportWithHistory() error { e.helper.orgDir = path.Join(e.rootDir, fmt.Sprintf("org_%d", org.ID)) e.status.Count["orgs"] += 1 } - err = e.helper.initOrg(e.sql, org.ID) + err = e.helper.initOrg(ctx, e.sql, org.ID) if err != nil { return err } diff --git a/pkg/services/export/object_store.go b/pkg/services/export/object_store.go index 990c4aca0c3..1b902e64589 100644 --- a/pkg/services/export/object_store.go +++ b/pkg/services/export/object_store.go @@ -28,7 +28,7 @@ type objectStoreJob struct { cfg ExportConfig broadcaster statusBroadcaster stopRequested bool - user *user.SignedInUser + ctx context.Context sess *session.SessionDB playlistService playlist.Service @@ -36,7 +36,7 @@ type objectStoreJob struct { dashboardsnapshots dashboardsnapshots.Service } -func startObjectStoreJob(user *user.SignedInUser, +func startObjectStoreJob(ctx context.Context, cfg ExportConfig, broadcaster statusBroadcaster, db db.DB, @@ -47,7 +47,7 @@ func startObjectStoreJob(user *user.SignedInUser, job := &objectStoreJob{ logger: log.New("export_to_object_store_job"), cfg: cfg, - user: user, + ctx: ctx, broadcaster: broadcaster, status: ExportStatus{ Running: true, @@ -63,7 +63,7 @@ func startObjectStoreJob(user *user.SignedInUser, } broadcaster(job.status) - go job.start() + go job.start(ctx) return job, nil } @@ -71,7 +71,7 @@ func (e *objectStoreJob) requestStop() { e.stopRequested = true } -func (e *objectStoreJob) start() { +func (e *objectStoreJob) start(ctx context.Context) { defer func() { e.logger.Info("Finished dummy export job") @@ -97,11 +97,11 @@ func (e *objectStoreJob) start() { e.logger.Info("Starting dummy export job") // Select all dashboards rowUser := &user.SignedInUser{ - Login: "?", + Login: "", OrgID: 0, // gets filled in from each row UserID: 0, } - ctx := store.ContextWithUser(context.Background(), rowUser) + ctx = store.ContextWithUser(ctx, rowUser) what := models.StandardKindDashboard e.status.Count[what] = 0 @@ -190,10 +190,12 @@ func (e *objectStoreJob) start() { orgIDs := []int64{1} what = "snapshot" for _, orgId := range orgIDs { + rowUser.OrgID = orgId + rowUser.UserID = 1 cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{ OrgId: orgId, Limit: 500000, - SignedInUser: e.user, + SignedInUser: rowUser, } err := e.dashboardsnapshots.SearchDashboardSnapshots(ctx, cmd) diff --git a/pkg/services/export/service.go b/pkg/services/export/service.go index fe85f03df0c..9867dfc279a 100644 --- a/pkg/services/export/service.go +++ b/pkg/services/export/service.go @@ -1,6 +1,7 @@ package export import ( + "context" "encoding/json" "fmt" "net/http" @@ -224,7 +225,7 @@ func (ex *StandardExport) HandleRequestExport(c *models.ReqContext) response.Res return response.Error(http.StatusLocked, "export already running", nil) } - user := store.UserFromContext(c.Req.Context()) + ctx := store.ContextWithUser(context.Background(), c.SignedInUser) var job Job broadcast := func(s ExportStatus) { ex.broadcastStatus(c.OrgID, s) @@ -233,13 +234,13 @@ func (ex *StandardExport) HandleRequestExport(c *models.ReqContext) response.Res case "dummy": job, err = startDummyExportJob(cfg, broadcast) case "objectStore": - job, err = startObjectStoreJob(user, cfg, broadcast, ex.db, ex.playlistService, ex.store, ex.dashboardsnapshotsService) + job, err = startObjectStoreJob(ctx, cfg, broadcast, ex.db, ex.playlistService, ex.store, ex.dashboardsnapshotsService) case "git": dir := filepath.Join(ex.dataDir, "export_git", fmt.Sprintf("git_%d", time.Now().Unix())) if err := os.MkdirAll(dir, os.ModePerm); err != nil { return response.Error(http.StatusBadRequest, "Error creating export folder", nil) } - job, err = startGitExportJob(cfg, ex.db, ex.dashboardsnapshotsService, dir, c.OrgID, broadcast, ex.playlistService, ex.orgService, ex.datasourceService) + job, err = startGitExportJob(ctx, cfg, ex.db, ex.dashboardsnapshotsService, dir, c.OrgID, broadcast, ex.playlistService, ex.orgService, ex.datasourceService) default: return response.Error(http.StatusBadRequest, "Unsupported job format", nil) } diff --git a/pkg/services/export/utils.go b/pkg/services/export/utils.go new file mode 100644 index 00000000000..567264c9c39 --- /dev/null +++ b/pkg/services/export/utils.go @@ -0,0 +1,29 @@ +package export + +import ( + "strings" + + "github.com/grafana/grafana/pkg/infra/db" +) + +func isTableNotExistsError(err error) bool { + txt := err.Error() + return strings.HasPrefix(txt, "no such table") || // SQLite + strings.HasSuffix(txt, " does not exist") || // PostgreSQL + strings.HasSuffix(txt, " doesn't exist") // MySQL +} + +func removeQuotesFromQuery(query string, remove bool) string { + if remove { + return strings.ReplaceAll(query, `"`, "") + } + return query +} + +func isMySQLEngine(sql db.DB) bool { + return sql.GetDBType() == "mysql" +} + +func isPostgreSQL(sql db.DB) bool { + return sql.GetDBType() == "postgres" +} diff --git a/pkg/services/store/object/sqlstash/sql_storage_server.go b/pkg/services/store/object/sqlstash/sql_storage_server.go index 4a10c75479e..04a78396ef7 100644 --- a/pkg/services/store/object/sqlstash/sql_storage_server.go +++ b/pkg/services/store/object/sqlstash/sql_storage_server.go @@ -552,6 +552,10 @@ func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRe func (s *sqlObjectServer) Search(ctx context.Context, r *object.ObjectSearchRequest) (*object.ObjectSearchResponse, error) { user := store.UserFromContext(ctx) + if user == nil { + return nil, fmt.Errorf("missing user in context") + } + if r.NextPageToken != "" || len(r.Sort) > 0 || len(r.Labels) > 0 { return nil, fmt.Errorf("not yet supported") } From 07e5f8117f1fc7729261a2109c1e05478fb1c64a Mon Sep 17 00:00:00 2001 From: sam boyer Date: Thu, 10 Nov 2022 15:36:40 -0500 Subject: [PATCH 190/926] Reconcile coremodels, entities, objects under new kind framework (#56492) * Update thema to latest * Deal with s/Library/*Runtime/ * Commit new, working results of codegen * We like pointers now * Always take runtime arg for NewBase() * Sketchy handwavy pass at entity meta framework * Little nibbles * Update pkg/framework/coremodel/entityframework.cue Co-authored-by: Artur Wierzbicki * Move file into new framework location * Introduce loaders, Go code * Complete rename to kind * Flesh out framework, add svg/dashboard examples * Cruft removal * Remove generated kind go files from gitignore * Refine maturity concept, add SlotKind * Update embed and go deps * Export PrefixWithGrafanaCUE * Make the loader actually work, holy crap * Many small tweaks to type.cue * Add Apache 2 licensing exceptions for kinds * Add new kinds dir, start of generator * Roll back to earlier oapi-codegen * Introduce new grafana-specific CUE loaders * Introduce new tidy code generators framework * Catch up kind framework with tinkering * Add slices for the generators * Add write/verify step to main generator * Many renames * Split up kind framework cue files * Use kind.Decl within generated kinds * Create kind.SomeDecl wrapper type to cache lineages * Better names again * Get one generated implemented, hopefully * Copy dashboard schema into new kind.cue * Small fixes to make the initial gen work * Put svg kind in its new home * Add generated Go dashboard type * More renames and cleanups * Add base kind registry and generator * Stop blacklisting *_gen.go files This is not the Go best practice, anyway. All we actually want to ignore for enterprise is generated wire files. * Change codegen output directories pkg/kind -> pkg/kinds pkg/registry/kindreg -> pkg/registry/corekind * Rename pkg/framework/kind to pkg/kindsys * Add core structured kind generator * Add plural and machine names to kind spec * Copy playlist over to kind system * Consolidate kindsys files * Add raw kind generator * Update CODEOWNERS for kind framework * Touch up comments a bit * More docs tweaks * Remove generated types to reduce noise for review * Split each generator into its own file * Rename Slot kind to Composable kind * Add handwavy types for customkind loading * Guard against init calls to framework loader * First pass at doc on extending the kind system * Improve attribute example in docs * Fix wire imports * Add basic TS types generator * Fix composable kind category def * No need for a separate file with generate directive * Catch dashboard schema up * Rename generator types to something saner and generic * Make version configurable in ts/go generators * Add CommonMeta to ease property access * Add kindsys prop indicating whether lineage is group * Put all kind categories back in a single file * Finish with kindsys group props * Refactor maturity progression per discussion - Replace "committed" with "merged" - All kindcats can use all maturity levels, at least for now * Convert ts veneer index generator to modular system * Move over to new jennywrites framework * Strip down old coremodel generator * Use public version of jennywrites * Pull latest thema * Commit generated Go types * Add header injection postprocessor * Move sdboyer/jennywrites to grafana/codejen * Tweak header output * Remove dashboard and playlist coremodels * Fix up backend dashboards devenv test * Fix TS import patterns to new gen filename * Update internal imports, remove coremodel registry * Fix compilation errors, wire generation * Export and replace the prefix dropper * More Go struct and field name changes * Last name fixes, hopefully * Fix lint errors * Last lint error Co-authored-by: Artur Wierzbicki --- .github/CODEOWNERS | 12 +- .gitignore | 10 +- LICENSING.md | 7 +- Makefile | 1 + embed.go | 2 +- go.mod | 10 +- go.sum | 15 +- kinds/gen.go | 157 ++++++++ kinds/raw/constraint.cue | 7 + kinds/raw/svg/svg_kind.cue | 4 + kinds/structured/constraint.cue | 8 + .../structured/dashboard/dashboard_kind.cue | 30 +- .../structured/playlist/playlist_kind.cue | 11 +- packages/grafana-schema/src/index.gen.ts | 35 +- ...ashboard.gen.ts => dashboard_types.gen.ts} | 34 +- ...{playlist.gen.ts => playlist_types.gen.ts} | 11 +- .../grafana-schema/src/schema/mudball.gen.ts | 2 +- .../src/veneer/dashboard.types.ts | 2 +- pkg/api/dashboard.go | 23 +- pkg/api/dashboard_test.go | 20 +- pkg/api/http_server.go | 8 +- pkg/cmd/grafana-cli/runner/wire.go | 6 +- pkg/codegen/astmanip_test.go | 45 ++- pkg/codegen/coremodel.go | 116 +----- pkg/codegen/generators.go | 61 +++ pkg/codegen/jenny_basecorereg.go | 62 ++++ pkg/codegen/jenny_corestructkind.go | 71 ++++ pkg/codegen/jenny_gotypes.go | 98 +++++ pkg/codegen/jenny_rawkind.go | 68 ++++ pkg/codegen/jenny_tstypes.go | 85 +++++ pkg/codegen/jenny_tsveneerindex.go | 325 ++++++++++++++++ pkg/codegen/pluggen.go | 10 +- pkg/codegen/tmpl.go | 9 +- pkg/codegen/tmpl/coremodel_registry.tmpl | 58 --- pkg/codegen/tmpl/kind_corestructured.tmpl | 95 +++++ pkg/codegen/tmpl/kind_raw.tmpl | 47 +++ pkg/codegen/tmpl/kind_registry.tmpl | 60 +++ pkg/codegen/util_go.go | 83 +++++ pkg/coremodel/playlist/playlist_gen.go | 136 ------- pkg/cuectx/ctx.go | 93 ++++- pkg/framework/coremodel/gen.go | 349 +----------------- .../coremodel/registry/assignability_test.go | 22 -- pkg/framework/coremodel/registry/provide.go | 48 --- .../coremodel/registry/registry_gen.go | 83 ----- pkg/framework/coremodel/slot/doc.go | 2 - pkg/{coremodel => kinds}/dashboard/addenda.go | 0 pkg/kinds/dashboard/dashboard_kind_gen.go | 104 ++++++ .../dashboard/dashboard_types_gen.go} | 287 ++------------ .../dashboard/dashboards_test.go | 9 +- pkg/kinds/playlist/playlist_kind_gen.go | 104 ++++++ pkg/kinds/playlist/playlist_types_gen.go | 59 +++ pkg/kinds/svg/svg_kind_gen.go | 54 +++ pkg/kindsys/EXTENDING.md | 60 +++ pkg/kindsys/errors.go | 35 ++ pkg/kindsys/kind.go | 82 ++++ pkg/kindsys/kindcats.cue | 166 +++++++++ pkg/kindsys/kindmetas.go | 74 ++++ pkg/kindsys/load.go | 242 ++++++++++++ pkg/registry/corekind/base.go | 76 ++++ pkg/registry/corekind/base_gen.go | 88 +++++ pkg/server/wire.go | 4 +- pkg/services/playlist/model.go | 4 +- .../publicdashboards/models/models.go | 2 +- .../publicdashboards/service/query_test.go | 8 +- pkg/services/store/kind/playlist/summary.go | 4 +- .../store/kind/playlist/summary_test.go | 4 +- public/app/features/playlist/types.ts | 2 +- public/app/plugins/gen.go | 1 - 68 files changed, 2702 insertions(+), 1208 deletions(-) create mode 100644 kinds/gen.go create mode 100644 kinds/raw/constraint.cue create mode 100644 kinds/raw/svg/svg_kind.cue create mode 100644 kinds/structured/constraint.cue rename pkg/coremodel/dashboard/coremodel.cue => kinds/structured/dashboard/dashboard_kind.cue (96%) rename pkg/coremodel/playlist/coremodel.cue => kinds/structured/playlist/playlist_kind.cue (94%) rename packages/grafana-schema/src/raw/dashboard/x/{dashboard.gen.ts => dashboard_types.gen.ts} (96%) rename packages/grafana-schema/src/raw/playlist/x/{playlist.gen.ts => playlist_types.gen.ts} (86%) create mode 100644 pkg/codegen/generators.go create mode 100644 pkg/codegen/jenny_basecorereg.go create mode 100644 pkg/codegen/jenny_corestructkind.go create mode 100644 pkg/codegen/jenny_gotypes.go create mode 100644 pkg/codegen/jenny_rawkind.go create mode 100644 pkg/codegen/jenny_tstypes.go create mode 100644 pkg/codegen/jenny_tsveneerindex.go delete mode 100644 pkg/codegen/tmpl/coremodel_registry.tmpl create mode 100644 pkg/codegen/tmpl/kind_corestructured.tmpl create mode 100644 pkg/codegen/tmpl/kind_raw.tmpl create mode 100644 pkg/codegen/tmpl/kind_registry.tmpl delete mode 100644 pkg/coremodel/playlist/playlist_gen.go delete mode 100644 pkg/framework/coremodel/registry/assignability_test.go delete mode 100644 pkg/framework/coremodel/registry/provide.go delete mode 100644 pkg/framework/coremodel/registry/registry_gen.go delete mode 100644 pkg/framework/coremodel/slot/doc.go rename pkg/{coremodel => kinds}/dashboard/addenda.go (100%) create mode 100644 pkg/kinds/dashboard/dashboard_kind_gen.go rename pkg/{coremodel/dashboard/dashboard_gen.go => kinds/dashboard/dashboard_types_gen.go} (62%) rename pkg/{coremodel => kinds}/dashboard/dashboards_test.go (91%) create mode 100644 pkg/kinds/playlist/playlist_kind_gen.go create mode 100644 pkg/kinds/playlist/playlist_types_gen.go create mode 100644 pkg/kinds/svg/svg_kind_gen.go create mode 100644 pkg/kindsys/EXTENDING.md create mode 100644 pkg/kindsys/errors.go create mode 100644 pkg/kindsys/kind.go create mode 100644 pkg/kindsys/kindcats.cue create mode 100644 pkg/kindsys/kindmetas.go create mode 100644 pkg/kindsys/load.go create mode 100644 pkg/registry/corekind/base.go create mode 100644 pkg/registry/corekind/base_gen.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 78df7648155..a7115b5455a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -218,7 +218,15 @@ lerna.json @grafana/frontend-ops # Grafana Partnerships Team /pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team -# Schema framework and code generation +# Kind system and code generation +embed.go @grafana/grafana-as-code +/kinds/ @grafana/grafana-as-code /pkg/codegen @grafana/grafana-as-code -/pkg/framework/coremodel @grafana/grafana-as-code +/pkg/kindsys @grafana/grafana-as-code +/pkg/kinds/*/*_gen.go @grafana/grafana-as-code +/pkg/registry/corekind @grafana/grafana-as-code /public/app/plugins/*gen.go @grafana/grafana-as-code + +# Specific core kinds +/kinds/raw/ @grafana/grafana-edge-squad +/kinds/structured/dashboard @grafana/dashboards-squad diff --git a/.gitignore b/.gitignore index a77f35f8747..c7f6ce8ec9e 100644 --- a/.gitignore +++ b/.gitignore @@ -170,14 +170,8 @@ compilation-stats.json # auto generated frontend docs /docs/sources/packages_api -# auto generated Go files -*_gen.go -!pkg/services/featuremgmt/toggles_gen.go -!pkg/coremodel/**/*_gen.go -!pkg/framework/**/*_gen.go -!pkg/plugins/pfs/**/*_gen.go -!public/app/plugins/**/*_gen.go -!pkg/services/publicdashboards/*_gen.go +# wire generated files +**/wire_gen.go # Auto-generated internationalization files public/locales/_build/ diff --git a/LICENSING.md b/LICENSING.md index fdb32e2906c..7b64973e012 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -17,10 +17,11 @@ packages/grafana-toolkit/ packages/grafana-ui/ packages/jaeger-ui-components/ packaging/ -pkg/coremodel/ -pkg/framework/coremodel/ +kinds/ +pkg/kinds/ +pkg/kindsys/ +pkg/registry/corekind/ grafana-mixin/ -cue/ public/app/plugins/datasource/tempo public/img/icons/solid/ public/img/icons/unicons/ diff --git a/Makefile b/Makefile index 41475f2c01c..df5598c1bf9 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ openapi3-gen: swagger-api-spec ## Generates OpenApi 3 specs from the Swagger 2 a ##@ Building gen-cue: ## Do all CUE/Thema code generation @echo "generate code from .cue files" + go generate ./kinds/gen.go go generate ./pkg/framework/coremodel go generate ./public/app/plugins diff --git a/embed.go b/embed.go index 779d369af34..c1353761781 100644 --- a/embed.go +++ b/embed.go @@ -6,5 +6,5 @@ import ( // CueSchemaFS embeds all schema-related CUE files in the Grafana project. // -//go:embed cue.mod/module.cue packages/grafana-schema/src/schema/*.cue public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json pkg/framework/coremodel/*.cue +//go:embed cue.mod/module.cue kinds/*/*.cue kinds/*/*/*.cue packages/grafana-schema/src/schema/*.cue public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json pkg/framework/coremodel/*.cue pkg/kindsys/*.cue var CueSchemaFS embed.FS diff --git a/go.mod b/go.mod index d49553bbf29..401f303c286 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/grafana/grafana-aws-sdk v0.11.0 github.com/grafana/grafana-azure-sdk-go v1.3.1 github.com/grafana/grafana-plugin-sdk-go v0.142.0 - github.com/grafana/thema v0.0.0-20220929145912-2c7c4a7bb20b + github.com/grafana/thema v0.0.0-20221107225215-00ad2949c7bc github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/hashicorp/go-hclog v1.0.0 github.com/hashicorp/go-plugin v1.4.3 @@ -110,7 +110,7 @@ require ( golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 - golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 + golang.org/x/sync v0.1.0 golang.org/x/time v0.0.0-20220609170525-579cf78fd858 golang.org/x/tools v0.1.12 gonum.org/v1/gonum v0.11.0 @@ -250,11 +250,13 @@ require ( github.com/bufbuild/connect-go v1.0.0 github.com/dlmiddlecote/sqlstats v1.0.2 github.com/drone/drone-cli v1.6.1 - github.com/getkin/kin-openapi v0.94.0 + github.com/getkin/kin-openapi v0.103.0 github.com/golang-migrate/migrate/v4 v4.7.0 github.com/google/go-github/v45 v45.2.0 + github.com/grafana/codejen v0.0.2 github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f github.com/jmoiron/sqlx v1.3.5 + github.com/kr/pretty v0.3.0 github.com/matryer/is v1.4.0 github.com/parca-dev/parca v0.12.1 github.com/urfave/cli v1.22.9 @@ -285,11 +287,13 @@ require ( github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/memberlist v0.4.0 // indirect + github.com/invopop/yaml v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-ieproxy v0.0.3 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.1.4 // indirect go.starlark.net v0.0.0-20221020143700-22309ac47eac // indirect diff --git a/go.sum b/go.sum index 6206eebfb47..4bd65ce99be 100644 --- a/go.sum +++ b/go.sum @@ -847,8 +847,9 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= -github.com/getkin/kin-openapi v0.94.0 h1:bAxg2vxgnHHHoeefVdmGbR+oxtJlcv5HsJJa3qmAHuo= github.com/getkin/kin-openapi v0.94.0/go.mod h1:LWZfzOd7PRy8GJ1dJ6mCU6tNdSfOwRac1BUPam4aw6Q= +github.com/getkin/kin-openapi v0.103.0 h1:F5wAtaQvPWxKCAYZ69LgHAThgu16p4u41VQtbn1U8LA= +github.com/getkin/kin-openapi v0.103.0/go.mod h1:w4lRPHiyOdwGbOkLIyk+P0qCwlu7TXPCHD/64nSXzgE= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo= github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0= @@ -1348,6 +1349,8 @@ github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc= github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/grafana/codejen v0.0.2 h1:Ssp27X7SOnYxaPUTByW/6201tNV5Q60l1BSF+s3lRP8= +github.com/grafana/codejen v0.0.2/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s= github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM= github.com/grafana/cuetsy v0.1.1/go.mod h1:4KWkUOslwvRTpEv7wdQG0jDFTuJmU+0L9x0h4kWxa2A= github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f h1:FvvSVEbnGeM2bUivGmsiXTi8URJyBU7TcFEEoRe5wWI= @@ -1367,8 +1370,8 @@ github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293 github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293/go.mod h1:HVHqK+BVPa/tmL8EMhLCCrPt2a1GdJpEyxr5hgur2UI= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc h1:1PY8n+rXuBNr3r1JQhoytWDCpc+pq+BibxV0SZv+Cr4= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4= -github.com/grafana/thema v0.0.0-20220929145912-2c7c4a7bb20b h1:OEGzlaj04LE6Eq7aGMOh0bCplGW5rXNeSSSwgamPBEY= -github.com/grafana/thema v0.0.0-20220929145912-2c7c4a7bb20b/go.mod h1:i3/NX50sNrwsPSAQAj56ckjQTb4biaYG/6y+zyKgpb0= +github.com/grafana/thema v0.0.0-20221107225215-00ad2949c7bc h1:Icv777/PBaqhLmbSBSDaajDl424cbmh5ee77Du2rUFE= +github.com/grafana/thema v0.0.0-20221107225215-00ad2949c7bc/go.mod h1:wnIJykzNiNVANl6g/Z4nkXxoMqaaH1LoG0IPNW++BEk= github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6 h1:I9dh1MXGX0wGyxdV/Sl7+ugnki4Dfsy8lv2s5Yf887o= github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -1553,6 +1556,8 @@ github.com/influxdata/roaring v0.4.13-0.20180809181101-fc520f41fab6/go.mod h1:bS github.com/influxdata/tdigest v0.0.0-20181121200506-bf2b5ad3c0a9/go.mod h1:Js0mqiSBE6Ffsg94weZZ2c+v/ciT8QRHFOap7EKDrR0= github.com/influxdata/tdigest v0.0.2-0.20210216194612-fc98d27c9e8b/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y= github.com/influxdata/usage-client v0.0.0-20160829180054-6d3895376368/go.mod h1:Wbbw6tYNvwa5dlB6304Sd+82Z3f7PmVZHVKU637d4po= +github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= @@ -2891,8 +2896,9 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ 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-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -3379,6 +3385,7 @@ gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= diff --git a/kinds/gen.go b/kinds/gen.go new file mode 100644 index 00000000000..8fd79172c7f --- /dev/null +++ b/kinds/gen.go @@ -0,0 +1,157 @@ +//go:build ignore +// +build ignore + +//go:generate go run gen.go + +package main + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "cuelang.org/go/cue/errors" + "github.com/grafana/codejen" + "github.com/grafana/grafana/pkg/codegen" + "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/grafana/pkg/kindsys" +) + +const sep = string(filepath.Separator) + +func main() { + if len(os.Args) > 1 { + fmt.Fprintf(os.Stderr, "plugin thema code generator does not currently accept any arguments\n, got %q", os.Args) + os.Exit(1) + } + + // Core kinds composite code generator. Produces all generated code in + // grafana/grafana that derives from raw and structured core kinds. + coreKindsGen := codejen.JennyListWithNamer[*codegen.DeclForGen](func(decl *codegen.DeclForGen) string { + return decl.Meta.Common().MachineName + }) + + // All the jennies that comprise the core kinds generator pipeline + coreKindsGen.Append( + codegen.GoTypesJenny(kindsys.GoCoreKindParentPath, nil), + codegen.CoreStructuredKindJenny(kindsys.GoCoreKindParentPath, nil), + codegen.RawKindJenny(kindsys.GoCoreKindParentPath, nil), + codegen.BaseCoreRegistryJenny(filepath.Join("pkg", "registry", "corekind"), kindsys.GoCoreKindParentPath), + codegen.TSTypesJenny(kindsys.TSCoreKindParentPath, &codegen.TSTypesGeneratorConfig{ + GenDirName: func(decl *codegen.DeclForGen) string { + // FIXME this hardcodes always generating to experimental dir. OK for now, but need generator fanout + return filepath.Join(decl.Meta.Common().MachineName, "x") + }, + }), + codegen.TSVeneerIndexJenny(filepath.Join("packages", "grafana-schema", "src")), + ) + + coreKindsGen.AddPostprocessors(codegen.SlashHeaderMapper("kinds/gen.go")) + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "could not get working directory: %s", err) + os.Exit(1) + } + grootp := strings.Split(cwd, sep) + groot := filepath.Join(sep, filepath.Join(grootp[:len(grootp)-1]...)) + + rt := cuectx.GrafanaThemaRuntime() + var all []*codegen.DeclForGen + + // structured kinddirs first + f := os.DirFS(filepath.Join(groot, kindsys.CoreStructuredDeclParentPath)) + kinddirs := elsedie(fs.ReadDir(f, "."))("error reading structured fs root directory") + for _, ent := range kinddirs { + if !ent.IsDir() { + continue + } + rel := filepath.Join(kindsys.CoreStructuredDeclParentPath, ent.Name()) + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rel, rt.Context(), nil) + if err != nil { + die(fmt.Errorf("%s is not a valid kind: %s", rel, errors.Details(err, nil))) + } + if decl.Meta.MachineName != ent.Name() { + die(fmt.Errorf("%s: kind's machine name (%s) must equal parent dir name (%s)", rel, decl.Meta.Name, ent.Name())) + } + + all = append(all, elsedie(codegen.ForGen(rt, decl.Some()))(rel)) + } + + // now raw kinddirs + f = os.DirFS(filepath.Join(groot, kindsys.RawDeclParentPath)) + kinddirs = elsedie(fs.ReadDir(f, "."))("error reading raw fs root directory") + for _, ent := range kinddirs { + if !ent.IsDir() { + continue + } + rel := filepath.Join(kindsys.RawDeclParentPath, ent.Name()) + decl, err := kindsys.LoadCoreKind[kindsys.RawMeta](rel, rt.Context(), nil) + if err != nil { + die(fmt.Errorf("%s is not a valid kind: %s", rel, errors.Details(err, nil))) + } + if decl.Meta.MachineName != ent.Name() { + die(fmt.Errorf("%s: kind's machine name (%s) must equal parent dir name (%s)", rel, decl.Meta.Name, ent.Name())) + } + dfg, _ := codegen.ForGen(nil, decl.Some()) + all = append(all, dfg) + } + + sort.Slice(all, func(i, j int) bool { + return nameFor(all[i].Meta) < nameFor(all[j].Meta) + }) + + jfs, err := coreKindsGen.GenerateFS(all) + if err != nil { + die(fmt.Errorf("core kinddirs codegen failed: %w", err)) + } + // for _, f := range jfs.AsFiles() { + // fmt.Println(filepath.Join(groot, f.RelativePath)) + // } + + if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { + if err = jfs.Verify(context.Background(), groot); err != nil { + die(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) + } + } else if err = jfs.Write(context.Background(), groot); err != nil { + die(fmt.Errorf("error while writing generated code to disk:\n%s", err)) + } +} + +func nameFor(m kindsys.SomeKindMeta) string { + switch x := m.(type) { + case kindsys.RawMeta: + return x.Name + case kindsys.CoreStructuredMeta: + return x.Name + case kindsys.CustomStructuredMeta: + return x.Name + case kindsys.ComposableMeta: + return x.Name + default: + // unreachable so long as all the possibilities in KindMetas have switch branches + panic("unreachable") + } +} + +func elsedie[T any](t T, err error) func(msg string) T { + if err != nil { + return func(msg string) T { + fmt.Fprintf(os.Stderr, "%s: %s\n", msg, err) + os.Exit(1) + return t + } + } + return func(msg string) T { + return t + } +} + +func die(err error) { + fmt.Fprint(os.Stderr, err, "\n") + os.Exit(1) +} diff --git a/kinds/raw/constraint.cue b/kinds/raw/constraint.cue new file mode 100644 index 00000000000..bcd29fd1f37 --- /dev/null +++ b/kinds/raw/constraint.cue @@ -0,0 +1,7 @@ +package kind + +import "github.com/grafana/grafana/pkg/kindsys" + +// In each child directory, the set of .cue files with 'package kind' +// must be an instance of kindsys.#Raw - a declaration of a raw kind. +kindsys.#Raw diff --git a/kinds/raw/svg/svg_kind.cue b/kinds/raw/svg/svg_kind.cue new file mode 100644 index 00000000000..0fff1c1186c --- /dev/null +++ b/kinds/raw/svg/svg_kind.cue @@ -0,0 +1,4 @@ +package kind + +name: "SVG" +extensions: ["svg"] diff --git a/kinds/structured/constraint.cue b/kinds/structured/constraint.cue new file mode 100644 index 00000000000..e3a5937767f --- /dev/null +++ b/kinds/structured/constraint.cue @@ -0,0 +1,8 @@ +package kind + +import "github.com/grafana/grafana/pkg/kindsys" + +// In each child directory, the set of .cue files with 'package kind' +// must be an instance of kindsys.#CoreStructured - a declaration of a +// structured kind. +kindsys.#CoreStructured diff --git a/pkg/coremodel/dashboard/coremodel.cue b/kinds/structured/dashboard/dashboard_kind.cue similarity index 96% rename from pkg/coremodel/dashboard/coremodel.cue rename to kinds/structured/dashboard/dashboard_kind.cue index 7fc09b3cb78..c0e9ddc6927 100644 --- a/pkg/coremodel/dashboard/coremodel.cue +++ b/kinds/structured/dashboard/dashboard_kind.cue @@ -1,18 +1,15 @@ -package dashboard +package kind -import ( - "strings" +import "strings" - "github.com/grafana/thema" -) +name: "Dashboard" +maturity: "merged" -thema.#Lineage -name: "dashboard" -seqs: [ +lineage: seqs: [ { schemas: [ {// 0.0 - @grafana(TSVeneer="type") + @grafana(TSVeneer="type") // Unique numeric identifier for the dashboard. // TODO must isolate or remove identifiers local to a Grafana instance...? @@ -84,12 +81,13 @@ seqs: [ /////////////////////////////////////// // Definitions (referenced above) are declared below + // TODO docs #AnnotationTarget: { - limit: int64 + limit: int64 matchAny: bool tags: [...string] type: string - } + } @cuetsy(kind="interface") @grafanamaturity(NeedsExpertReview) // TODO docs // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts @@ -111,9 +109,9 @@ seqs: [ iconColor?: string @grafanamaturity(NeedsExpertReview) type: string | *"dashboard" @grafanamaturity(NeedsExpertReview) // Query for annotation data. - rawQuery?: string @grafanamaturity(NeedsExpertReview) - showIn: uint8 | *0 @grafanamaturity(NeedsExpertReview) - target?: #AnnotationTarget @grafanamaturity(NeedsExpertReview) + rawQuery?: string @grafanamaturity(NeedsExpertReview) + showIn: uint8 | *0 @grafanamaturity(NeedsExpertReview) + target?: #AnnotationTarget @grafanamaturity(NeedsExpertReview) } @cuetsy(kind="interface") // FROM: packages/grafana-data/src/types/templateVars.ts @@ -237,7 +235,7 @@ seqs: [ #SpecialValueMap: { type: #MappingType & "special" options: { - match: "true" | "false" + match: "true" | "false" pattern: string result: #ValueMappingResult } @@ -376,7 +374,7 @@ seqs: [ // Human readable field metadata description?: string @grafanamaturity(NeedsExpertReview) - // An explict path to the field in the datasource. When the frame meta includes a path, + // An explicit path to the field in the datasource. When the frame meta includes a path, // This will default to `${frame.meta.path}/${field.name} // // When defined, this value can be used as an identifier within the datasource scope, and diff --git a/pkg/coremodel/playlist/coremodel.cue b/kinds/structured/playlist/playlist_kind.cue similarity index 94% rename from pkg/coremodel/playlist/coremodel.cue rename to kinds/structured/playlist/playlist_kind.cue index f12338f1d61..6b3865b9d85 100644 --- a/pkg/coremodel/playlist/coremodel.cue +++ b/kinds/structured/playlist/playlist_kind.cue @@ -1,12 +1,9 @@ -package playlist +package kind -import ( - "github.com/grafana/thema" -) +name: "Playlist" +maturity: "merged" -thema.#Lineage -name: "playlist" -seqs: [ +lineage: seqs: [ { schemas: [ {//0.0 diff --git a/packages/grafana-schema/src/index.gen.ts b/packages/grafana-schema/src/index.gen.ts index 6b17d832f7a..459c68aa6d7 100644 --- a/packages/grafana-schema/src/index.gen.ts +++ b/packages/grafana-schema/src/index.gen.ts @@ -1,11 +1,15 @@ -// This file is autogenerated. DO NOT EDIT. +// THIS FILE IS GENERATED. EDITING IS FUTILE. // -// Generated by pkg/framework/coremodel/gen.go +// Generated by: +// kinds/gen.go +// Using jennies: +// TSVeneerIndexJenny // -// Run `make gen-cue` from repository root to regenerate. +// Run 'make gen-cue' from repository root to regenerate. -// Raw generated types from dashboard entity type. +// Raw generated types from Dashboard kind. export type { + AnnotationTarget, AnnotationQuery, VariableModel, DashboardLink, @@ -30,10 +34,11 @@ export type { DashboardCursorSync, MatcherConfig, RowPanel -} from './raw/dashboard/x/dashboard.gen'; +} from './raw/dashboard/x/dashboard_types.gen'; -// Raw generated default consts from dashboard entity type. +// Raw generated default consts from dashboard kind. export { + defaultAnnotationTarget, defaultAnnotationQuery, defaultDashboardLink, defaultGridPos, @@ -41,10 +46,10 @@ export { defaultDashboardCursorSync, defaultMatcherConfig, defaultRowPanel -} from './raw/dashboard/x/dashboard.gen'; +} from './raw/dashboard/x/dashboard_types.gen'; -// The following exported declarations correspond to types in the dashboard@0.0 schema with -// attribute @grafana(TSVeneer="type"). (lineage declared in file: pkg/coremodel/dashboard/coremodel.cue) +// The following exported declarations correspond to types in the dashboard@0.0 kind's +// schema with attribute @grafana(TSVeneer="type"). // // The handwritten file for these type and default veneers is expected to be at // packages/grafana-schema/src/veneer/dashboard.types.ts. @@ -59,8 +64,8 @@ export type { FieldConfig } from './veneer/dashboard.types'; -// The following exported declarations correspond to types in the dashboard@0.0 schema with -// attribute @grafana(TSVeneer="type"). (lineage declared in file: pkg/coremodel/dashboard/coremodel.cue) +// The following exported declarations correspond to types in the dashboard@0.0 kind's +// schema with attribute @grafana(TSVeneer="type"). // // The handwritten file for these type and default veneers is expected to be at // packages/grafana-schema/src/veneer/dashboard.types.ts. @@ -75,11 +80,11 @@ export { defaultFieldConfig } from './veneer/dashboard.types'; -// Raw generated types from playlist entity type. +// Raw generated types from Playlist kind. export type { Playlist, PlaylistItem -} from './raw/playlist/x/playlist.gen'; +} from './raw/playlist/x/playlist_types.gen'; -// Raw generated default consts from playlist entity type. -export { defaultPlaylist } from './raw/playlist/x/playlist.gen'; +// Raw generated default consts from playlist kind. +export { defaultPlaylist } from './raw/playlist/x/playlist_types.gen'; diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts similarity index 96% rename from packages/grafana-schema/src/raw/dashboard/x/dashboard.gen.ts rename to packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 50571f93aa5..7bcf55da260 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -1,10 +1,25 @@ -// This file is autogenerated. DO NOT EDIT. +// THIS FILE IS GENERATED. EDITING IS FUTILE. // -// Generated by pkg/framework/coremodel/gen.go +// Generated by: +// kinds/gen.go +// Using jennies: +// TSTypesJenny // -// Derived from the Thema lineage declared in pkg/coremodel/dashboard/coremodel.cue -// -// Run `make gen-cue` from repository root to regenerate. +// Run 'make gen-cue' from repository root to regenerate. + +/** + * TODO docs + */ +export interface AnnotationTarget { + limit: number; + matchAny: boolean; + tags: Array; + type: string; +} + +export const defaultAnnotationTarget: Partial = { + tags: [], +}; /** * TODO docs @@ -40,12 +55,7 @@ export interface AnnotationQuery { */ rawQuery?: string; showIn: number; - target?: { - limit: number; - matchAny: boolean; - tags: Array; - type: string; - }; + target?: AnnotationTarget; type: string; } @@ -493,7 +503,7 @@ export interface FieldConfig { */ noValue?: string; /** - * An explict path to the field in the datasource. When the frame meta includes a path, + * An explicit path to the field in the datasource. When the frame meta includes a path, * This will default to `${frame.meta.path}/${field.name} * * When defined, this value can be used as an identifier within the datasource scope, and diff --git a/packages/grafana-schema/src/raw/playlist/x/playlist.gen.ts b/packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts similarity index 86% rename from packages/grafana-schema/src/raw/playlist/x/playlist.gen.ts rename to packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts index 3a562777c0c..1e1ceeabd8d 100644 --- a/packages/grafana-schema/src/raw/playlist/x/playlist.gen.ts +++ b/packages/grafana-schema/src/raw/playlist/x/playlist_types.gen.ts @@ -1,10 +1,11 @@ -// This file is autogenerated. DO NOT EDIT. +// THIS FILE IS GENERATED. EDITING IS FUTILE. // -// Generated by pkg/framework/coremodel/gen.go +// Generated by: +// kinds/gen.go +// Using jennies: +// TSTypesJenny // -// Derived from the Thema lineage declared in pkg/coremodel/playlist/coremodel.cue -// -// Run `make gen-cue` from repository root to regenerate. +// Run 'make gen-cue' from repository root to regenerate. export interface PlaylistItem { /** diff --git a/packages/grafana-schema/src/schema/mudball.gen.ts b/packages/grafana-schema/src/schema/mudball.gen.ts index f29d781111d..803c8aced88 100644 --- a/packages/grafana-schema/src/schema/mudball.gen.ts +++ b/packages/grafana-schema/src/schema/mudball.gen.ts @@ -1,4 +1,4 @@ -//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~ // This file is autogenerated. DO NOT EDIT. // // To regenerate, run "make gen-cue" from the repository root. diff --git a/packages/grafana-schema/src/veneer/dashboard.types.ts b/packages/grafana-schema/src/veneer/dashboard.types.ts index 4dc0c1b0297..013d76db039 100644 --- a/packages/grafana-schema/src/veneer/dashboard.types.ts +++ b/packages/grafana-schema/src/veneer/dashboard.types.ts @@ -1,4 +1,4 @@ -import * as raw from '../raw/dashboard/x/dashboard.gen'; +import * as raw from '../raw/dashboard/x/dashboard_types.gen'; export interface Dashboard extends raw.Dashboard { panels?: Array< diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 2f3f335d389..926af720944 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -16,9 +16,8 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/dashdiffs" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/coremodel/dashboard" - "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/kinds/dashboard" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" @@ -365,21 +364,22 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext) response.Response { } if hs.Features.IsEnabled(featuremgmt.FlagValidateDashboardsOnSave) { - cm := hs.Coremodels.Dashboard() + kind := hs.Kinds.Dashboard() + dashbytes, err := cmd.Dashboard.Bytes() + if err != nil { + return response.Error(http.StatusBadRequest, "unable to parse dashboard", err) + } // Ideally, coremodel validation calls would be integrated into the web // framework. But this does the job for now. schv, err := cmd.Dashboard.Get("schemaVersion").Int() // Only try to validate if the schemaVersion is at least the handoff version // (the minimum schemaVersion against which the dashboard schema is known to - // work), or if schemaVersion is absent (which will happen once the Thema - // schema becomes canonical). + // work), or if schemaVersion is absent (which will happen once the kind schema + // becomes canonical). if err != nil || schv >= dashboard.HandoffSchemaVersion { - // Can't fail, web.Bind() already ensured it's valid JSON - b, _ := cmd.Dashboard.Bytes() - v, _ := cuectx.JSONtoCUE("dashboard.json", b) - if _, err := cm.CurrentSchema().Validate(v); err != nil { + if _, _, err := kind.JSONValueMux(dashbytes); err != nil { return response.Error(http.StatusBadRequest, "invalid dashboard json", err) } } @@ -772,7 +772,7 @@ func (hs *HTTPServer) ValidateDashboard(c *models.ReqContext) response.Response return response.Error(http.StatusBadRequest, "bad request data", err) } - cm := hs.Coremodels.Dashboard() + dk := hs.Kinds.Dashboard() dashboardBytes := []byte(cmd.Dashboard) // POST api receives dashboard as a string of json (so line numbers for errors stay consistent), @@ -793,8 +793,7 @@ func (hs *HTTPServer) ValidateDashboard(c *models.ReqContext) response.Response // work), or if schemaVersion is absent (which will happen once the Thema // schema becomes canonical). if err != nil || schemaVersion >= dashboard.HandoffSchemaVersion { - v, _ := cuectx.JSONtoCUE("dashboard.json", dashboardBytes) - _, validationErr := cm.CurrentSchema().Validate(v) + _, _, validationErr := dk.JSONValueMux(dashboardBytes) if validationErr == nil { isValid = true diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index b4fc7c52e3c..bd4a7f08b3d 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -17,11 +17,11 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/framework/coremodel/registry" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry/corekind" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -65,7 +65,7 @@ func TestGetHomeDashboard(t *testing.T) { SQLStore: mockstore.NewSQLStoreMock(), preferenceService: prefService, dashboardVersionService: dashboardVersionService, - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } tests := []struct { @@ -149,7 +149,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { Features: featuremgmt.WithFeatures(), DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } setUp := func() { @@ -271,7 +271,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, Features: featuremgmt.WithFeatures(), - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } setUp := func() { @@ -968,7 +968,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { AccessControl: accesscontrolmock.New(), DashboardService: dashboardService, Features: featuremgmt.WithFeatures(), - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } hs.callGetDashboard(sc) @@ -1023,7 +1023,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr ), DashboardService: dashboardService, Features: featuremgmt.WithFeatures(), - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } hs.callGetDashboard(sc) @@ -1089,7 +1089,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s DashboardService: dashboardService, folderService: folderService, Features: featuremgmt.WithFeatures(), - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } sc := setupScenarioContext(t, url) @@ -1121,7 +1121,7 @@ func postValidateScenario(t *testing.T, desc string, url string, routePattern st LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, Features: featuremgmt.WithFeatures(), - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } sc := setupScenarioContext(t, url) @@ -1158,7 +1158,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string SQLStore: sqlmock, dashboardVersionService: fakeDashboardVersionService, Features: featuremgmt.WithFeatures(), - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } sc := setupScenarioContext(t, url) @@ -1197,7 +1197,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout SQLStore: sqlStore, Features: featuremgmt.WithFeatures(), dashboardVersionService: fakeDashboardVersionService, - Coremodels: registry.NewBase(nil), + Kinds: corekind.NewBase(nil), } sc := setupScenarioContext(t, url) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 8ecd9639cf4..9392c03a911 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -29,7 +29,6 @@ import ( "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/framework/coremodel/registry" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" @@ -41,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/plugincontext" + "github.com/grafana/grafana/pkg/registry/corekind" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations" @@ -192,7 +192,7 @@ type HTTPServer struct { dashboardVersionService dashver.Service PublicDashboardsApi *publicdashboardsApi.Api starService star.Service - Coremodels *registry.Base + Kinds *corekind.Base playlistService playlist.Service apiKeyService apikey.Service kvStore kvstore.KVStore @@ -241,7 +241,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service, teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service, - starService star.Service, csrfService csrf.Service, coremodels *registry.Base, + starService star.Service, csrfService csrf.Service, basekinds *corekind.Base, playlistService playlist.Service, apiKeyService apikey.Service, kvStore kvstore.KVStore, secretsMigrator secrets.Migrator, secretsPluginManager plugins.SecretsPluginManager, secretsService secrets.Service, secretsPluginMigrator spm.SecretMigrationProvider, secretsStore secretsKV.SecretsKVStore, @@ -337,7 +337,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dashboardPermissionsService: dashboardPermissionsService, dashboardVersionService: dashboardVersionService, starService: starService, - Coremodels: coremodels, + Kinds: basekinds, playlistService: playlistService, apiKeyService: apiKeyService, kvStore: kvStore, diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 49819799069..366e0137675 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -8,7 +8,7 @@ import ( "github.com/google/wire" "github.com/grafana/grafana/pkg/tsdb/parca" - phlare "github.com/grafana/grafana/pkg/tsdb/phlare" + "github.com/grafana/grafana/pkg/tsdb/phlare" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/api" @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/expr" - cmreg "github.com/grafana/grafana/pkg/framework/coremodel/registry" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/httpclient" @@ -47,6 +46,7 @@ import ( managerStore "github.com/grafana/grafana/pkg/plugins/manager/store" "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/plugins/repo" + "github.com/grafana/grafana/pkg/registry/corekind" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" @@ -305,7 +305,7 @@ var wireSet = wire.NewSet( avatar.ProvideAvatarCacheServer, authproxy.ProvideAuthProxy, statscollector.ProvideService, - cmreg.CoremodelSet, + corekind.KindSet, cuectx.GrafanaCUEContext, cuectx.GrafanaThemaRuntime, csrf.ProvideCSRFFilter, diff --git a/pkg/codegen/astmanip_test.go b/pkg/codegen/astmanip_test.go index 0f44a112f39..e09e1071b16 100644 --- a/pkg/codegen/astmanip_test.go +++ b/pkg/codegen/astmanip_test.go @@ -29,7 +29,7 @@ type FooThing struct { }`, out: `package foo -type Model struct { +type Foo struct { Id int64 Ref Thing } @@ -52,7 +52,7 @@ type FooThing struct { }`, out: `package foo -type Model struct { +type Foo struct { Id int64 Ref *Thing } @@ -77,7 +77,7 @@ type FooThing struct { }`, out: `package foo -type Model struct { +type Foo struct { Id int64 Ref []Thing PRef []*Thing @@ -104,7 +104,7 @@ type FooThing struct { }`, out: `package foo -type Model struct { +type Foo struct { Id int64 KeyRef map[Thing]string ValRef map[string]Thing @@ -132,7 +132,7 @@ type FooThing struct { }`, out: `package foo -type Model struct { +type Foo struct { Id int64 KeyRef map[*Thing]string ValRef map[string]*Thing @@ -154,7 +154,7 @@ type Foo struct { }`, out: `package foo -type Model struct { +type Foo struct { Id int64 FooRef []string } @@ -235,6 +235,37 @@ type Thing string // of objects, only types, so we shouldn't encounter this case. skip: true, }, + "comments": { + in: `package foo + +// Foo is a thing. It should be Foo still. +type Foo struct { + Id int64 + Ref FooThing +} + +// FooThing is also a thing. We want [FooThing] to be known properly. +// Even if FooThing +// were not a FooThing, in our minds, forever shall it be FooThing. +type FooThing struct { + Id int64 +}`, + out: `package foo + +// Foo is a thing. It should be Foo still. +type Foo struct { + Id int64 + Ref Thing +} + +// Thing is also a thing. We want [Thing] to be known properly. +// Even if Thing +// were not a Thing, in our minds, forever shall it be Thing. +type Thing struct { + Id int64 +} +`, + }, } for name, it := range tt { @@ -250,7 +281,7 @@ type Thing string t.Fatal(err) } - drop := makePrefixDropper("Foo", "Model") + drop := PrefixDropper("Foo") astutil.Apply(inf, drop, nil) buf := new(bytes.Buffer) err = format.Node(buf, fset, inf) diff --git a/pkg/codegen/coremodel.go b/pkg/codegen/coremodel.go index cfdc78e1e30..d3b8ae92464 100644 --- a/pkg/codegen/coremodel.go +++ b/pkg/codegen/coremodel.go @@ -4,11 +4,9 @@ import ( "bytes" "errors" "fmt" - "go/ast" "io" "os" "path/filepath" - "regexp" "strings" "testing/fstest" @@ -21,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/thema" "github.com/grafana/thema/encoding/openapi" - "golang.org/x/tools/go/ast/astutil" ) // CoremodelDeclaration contains the results of statically analyzing a Grafana @@ -218,7 +215,7 @@ func (cd *CoremodelDeclaration) GenerateGoCoremodel(path string) (WriteDiffer, e fullp := filepath.Join(path, fmt.Sprintf("%s_gen.go", lin.Name())) byt, err := postprocessGoFile(genGoFile{ path: fullp, - walker: makePrefixDropper(strings.Title(lin.Name()), "Model"), + walker: PrefixDropper(strings.Title(lin.Name())), in: buf.Bytes(), }) if err != nil { @@ -273,117 +270,6 @@ func (cd *CoremodelDeclaration) GenerateTypescriptCoremodel() (*tsast.File, erro return tf, nil } -type prefixDropper struct { - str string - base string - rxp *regexp.Regexp - rxpsuff *regexp.Regexp -} - -func makePrefixDropper(str, base string) astutil.ApplyFunc { - return (&prefixDropper{ - str: str, - base: base, - rxpsuff: regexp.MustCompile(fmt.Sprintf(`%s([a-zA-Z_]*)`, str)), - rxp: regexp.MustCompile(fmt.Sprintf(`%s([\s.,;-])`, str)), - }).applyfunc -} - -func depoint(e ast.Expr) ast.Expr { - if star, is := e.(*ast.StarExpr); is { - return star.X - } - return e -} - -func (d prefixDropper) applyfunc(c *astutil.Cursor) bool { - n := c.Node() - - // fmt.Printf("%T %s\n", c.Node(), ast.Print(nil, c.Node())) - switch x := n.(type) { - case *ast.ValueSpec: - // fmt.Printf("%T %s\n", c.Node(), ast.Print(nil, c.Node())) - d.handleExpr(x.Type) - for _, id := range x.Names { - d.do(id) - } - case *ast.TypeSpec: - // Always do typespecs - d.do(x.Name) - case *ast.Field: - // Don't rename struct fields. We just want to rename type declarations, and - // field value specifications that reference those types. - d.handleExpr(x.Type) - // return false - - case *ast.CommentGroup: - for _, c := range x.List { - c.Text = d.rxp.ReplaceAllString(c.Text, d.base+"$1") - c.Text = d.rxpsuff.ReplaceAllString(c.Text, "$1") - } - } - return true -} - -func (d prefixDropper) handleExpr(e ast.Expr) { - // Deref a StarExpr, if there is one - expr := depoint(e) - switch x := expr.(type) { - case *ast.Ident: - d.do(x) - case *ast.ArrayType: - if id, is := depoint(x.Elt).(*ast.Ident); is { - d.do(id) - } - case *ast.MapType: - if id, is := depoint(x.Key).(*ast.Ident); is { - d.do(id) - } - if id, is := depoint(x.Value).(*ast.Ident); is { - d.do(id) - } - } -} - -func (d prefixDropper) do(n *ast.Ident) { - if n.Name != d.str { - n.Name = strings.TrimPrefix(n.Name, d.str) - } else { - n.Name = d.base - } -} - -// GenerateCoremodelRegistry produces Go files that define a registry with -// references to all the Go code that is expected to be generated from the -// provided lineages. -func GenerateCoremodelRegistry(path string, ecl []*CoremodelDeclaration) (WriteDiffer, error) { - var cml []tplVars - for _, ec := range ecl { - cml = append(cml, ec.toTemplateObj()) - } - - buf := new(bytes.Buffer) - if err := tmpls.Lookup("coremodel_registry.tmpl").Execute(buf, tvars_coremodel_registry{ - Header: tvars_autogen_header{ - GeneratorPath: "pkg/framework/coremodel/gen.go", // FIXME hardcoding is not OK - }, - Coremodels: cml, - }); err != nil { - return nil, fmt.Errorf("failed executing coremodel registry template: %w", err) - } - - byt, err := postprocessGoFile(genGoFile{ - path: path, - in: buf.Bytes(), - }) - if err != nil { - return nil, err - } - wd := NewWriteDiffer() - wd[path] = byt - return wd, nil -} - var tmplTypedef = `{{range .Types}} {{ with .Schema.Description }}{{ . }}{{ else }}// {{.TypeName}} is the Go representation of a {{.JsonName}}.{{ end }} // diff --git a/pkg/codegen/generators.go b/pkg/codegen/generators.go new file mode 100644 index 00000000000..c6090dfe4f5 --- /dev/null +++ b/pkg/codegen/generators.go @@ -0,0 +1,61 @@ +package codegen + +import ( + "bytes" + "fmt" + + "github.com/grafana/codejen" + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" +) + +type OneToOne codejen.OneToOne[*DeclForGen] +type OneToMany codejen.OneToMany[*DeclForGen] +type ManyToOne codejen.ManyToOne[*DeclForGen] +type ManyToMany codejen.ManyToMany[*DeclForGen] + +// ForGen is a codejen input transformer that converts a pure kindsys.SomeDecl into +// a DeclForGen by binding its contained lineage. +func ForGen(rt *thema.Runtime, decl *kindsys.SomeDecl) (*DeclForGen, error) { + lin, err := decl.BindKindLineage(rt) + if err != nil { + return nil, err + } + + return &DeclForGen{ + SomeDecl: decl, + lin: lin, + }, nil +} + +// DeclForGen wraps [kindsys.SomeDecl] to provide trivial caching of +// the lineage declared by the kind (nil for raw kinds). +type DeclForGen struct { + *kindsys.SomeDecl + lin thema.Lineage +} + +func (decl *DeclForGen) Lineage() thema.Lineage { + return decl.lin +} + +func SlashHeaderMapper(maingen string) codejen.FileMapper { + return func(f codejen.File) (codejen.File, error) { + b := new(bytes.Buffer) + fmt.Fprintf(b, headerTmpl, maingen, f.FromString()) + fmt.Fprint(b, string(f.Data)) + f.Data = b.Bytes() + return f, nil + } +} + +var headerTmpl = `// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// %s +// Using jennies: +// %s +// +// Run 'make gen-cue' from repository root to regenerate. + +` diff --git a/pkg/codegen/jenny_basecorereg.go b/pkg/codegen/jenny_basecorereg.go new file mode 100644 index 00000000000..96298b61cef --- /dev/null +++ b/pkg/codegen/jenny_basecorereg.go @@ -0,0 +1,62 @@ +package codegen + +import ( + "bytes" + "fmt" + "path/filepath" + + "github.com/grafana/codejen" +) + +// BaseCoreRegistryJenny generates a static registry for core kinds that +// only initializes their [kindsys.Interface]. No slot kinds are composed. +// +// Path should be the relative path to the directory that will contain the +// generated registry. kindrelroot should be the repo-root-relative path to the +// parent directory to all directories that contain generated kind bindings +// (e.g. pkg/kind). +func BaseCoreRegistryJenny(path, kindrelroot string) ManyToOne { + return &genBaseRegistry{ + path: path, + kindrelroot: kindrelroot, + } +} + +type genBaseRegistry struct { + path string + kindrelroot string +} + +func (gen *genBaseRegistry) JennyName() string { + return "BaseCoreRegistryJenny" +} + +func (gen *genBaseRegistry) Generate(decls []*DeclForGen) (*codejen.File, error) { + var numRaw int + for _, k := range decls { + if k.IsRaw() { + numRaw++ + } + } + + buf := new(bytes.Buffer) + if err := tmpls.Lookup("kind_registry.tmpl").Execute(buf, tvars_kind_registry{ + NumRaw: numRaw, + NumStructured: len(decls) - numRaw, + PackageName: filepath.Base(gen.path), + KindPackagePrefix: filepath.ToSlash(filepath.Join("github.com/grafana/grafana", gen.kindrelroot)), + Kinds: decls, + }); err != nil { + return nil, fmt.Errorf("failed executing kind registry template: %w", err) + } + + b, err := postprocessGoFile(genGoFile{ + path: gen.path, + in: buf.Bytes(), + }) + if err != nil { + return nil, err + } + + return codejen.NewFile(filepath.Join(gen.path, "base_gen.go"), b, gen), nil +} diff --git a/pkg/codegen/jenny_corestructkind.go b/pkg/codegen/jenny_corestructkind.go new file mode 100644 index 00000000000..0d1fad63e17 --- /dev/null +++ b/pkg/codegen/jenny_corestructkind.go @@ -0,0 +1,71 @@ +package codegen + +import ( + "bytes" + "fmt" + "path/filepath" + + "github.com/grafana/codejen" +) + +// CoreStructuredKindJenny generates the implementation of +// [kindsys.Structured] for the provided kind declaration. +// +// gokindsdir should be the relative path to the parent directory that contains +// all generated kinds. +// +// This generator only has output for core structured kinds. +func CoreStructuredKindJenny(gokindsdir string, cfg *CoreStructuredKindGeneratorConfig) OneToOne { + if cfg == nil { + cfg = new(CoreStructuredKindGeneratorConfig) + } + if cfg.GenDirName == nil { + cfg.GenDirName = func(decl *DeclForGen) string { + return decl.Meta.Common().MachineName + } + } + + return &genCoreStructuredKind{ + gokindsdir: gokindsdir, + cfg: cfg, + } +} + +// CoreStructuredKindGeneratorConfig holds configuration options for [CoreStructuredKindJenny]. +type CoreStructuredKindGeneratorConfig struct { + // GenDirName returns the name of the directory in which the file should be + // generated. Defaults to DeclForGen.Lineage().Name() if nil. + GenDirName func(*DeclForGen) string +} + +type genCoreStructuredKind struct { + gokindsdir string + cfg *CoreStructuredKindGeneratorConfig +} + +var _ OneToOne = &genCoreStructuredKind{} + +func (gen *genCoreStructuredKind) JennyName() string { + return "CoreStructuredKindJenny" +} + +func (gen *genCoreStructuredKind) Generate(decl *DeclForGen) (*codejen.File, error) { + if !decl.IsCoreStructured() { + return nil, nil + } + + path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(decl), decl.Meta.Common().MachineName+"_kind_gen.go") + buf := new(bytes.Buffer) + if err := tmpls.Lookup("kind_corestructured.tmpl").Execute(buf, decl); err != nil { + return nil, fmt.Errorf("failed executing kind_corestructured template for %s: %w", path, err) + } + b, err := postprocessGoFile(genGoFile{ + path: path, + in: buf.Bytes(), + }) + if err != nil { + return nil, err + } + + return codejen.NewFile(path, b, gen), nil +} diff --git a/pkg/codegen/jenny_gotypes.go b/pkg/codegen/jenny_gotypes.go new file mode 100644 index 00000000000..5d933ee6857 --- /dev/null +++ b/pkg/codegen/jenny_gotypes.go @@ -0,0 +1,98 @@ +package codegen + +import ( + "fmt" + "path/filepath" + + "github.com/grafana/codejen" + "github.com/grafana/thema" + "github.com/grafana/thema/encoding/gocode" + "golang.org/x/tools/go/ast/astutil" +) + +// GoTypesJenny creates a [OneToOne] that produces Go types for the latest +// Thema schema in a structured kind's lineage. +// +// At minimum, a gokindsdir must be provided. This should be the path to the parent +// directory of the directory in which the types should be generated, relative +// to the project root. For example, if the types for a kind named "foo" +// should live at pkg/kind/foo/foo_gen.go, relpath should be "pkg/kind". +// +// This generator is a no-op for raw kinds. +func GoTypesJenny(gokindsdir string, cfg *GoTypesGeneratorConfig) OneToOne { + if cfg == nil { + cfg = new(GoTypesGeneratorConfig) + } + if cfg.GenDirName == nil { + cfg.GenDirName = func(decl *DeclForGen) string { + return decl.Meta.Common().MachineName + } + } + + return &genGoTypes{ + gokindsdir: gokindsdir, + cfg: cfg, + } +} + +// GoTypesGeneratorConfig holds configuration options for [GoTypesJenny]. +type GoTypesGeneratorConfig struct { + // Apply is an optional AST manipulation func that, if provided, will be run + // against the generated Go file prior to running it through goimports. + Apply astutil.ApplyFunc + + // GenDirName returns the name of the parent directory in which the type file + // should be generated. If nil, the DeclForGen.Lineage().Name() will be used. + GenDirName func(*DeclForGen) string + + // Version of the schema to generate. If nil, latest is generated. + Version *thema.SyntacticVersion +} + +type genGoTypes struct { + gokindsdir string + cfg *GoTypesGeneratorConfig +} + +func (gen *genGoTypes) JennyName() string { + return "GoTypesJenny" +} + +func (gen *genGoTypes) Generate(decl *DeclForGen) (*codejen.File, error) { + if decl.IsRaw() { + return nil, nil + } + + var sch thema.Schema + var err error + + lin := decl.Lineage() + if gen.cfg.Version == nil { + sch = lin.Latest() + } else { + sch, err = lin.Schema(*gen.cfg.Version) + if err != nil { + return nil, fmt.Errorf("error in configured version for %s generator: %w", *gen.cfg.Version, err) + } + } + + // always drop prefixes. + var appf []astutil.ApplyFunc + if gen.cfg.Apply != nil { + appf = append(appf, gen.cfg.Apply) + } + appf = append(appf, PrefixDropper(decl.Meta.Common().Name)) + + pdir := gen.cfg.GenDirName(decl) + fpath := filepath.Join(gen.gokindsdir, pdir, lin.Name()+"_types_gen.go") + // TODO allow using name instead of machine name in thema generator + b, err := gocode.GenerateTypesOpenAPI(sch, &gocode.TypeConfigOpenAPI{ + PackageName: filepath.Base(pdir), + ApplyFuncs: appf, + }) + if err != nil { + return nil, err + } + + return codejen.NewFile(fpath, b, gen), nil +} diff --git a/pkg/codegen/jenny_rawkind.go b/pkg/codegen/jenny_rawkind.go new file mode 100644 index 00000000000..45c1675a01b --- /dev/null +++ b/pkg/codegen/jenny_rawkind.go @@ -0,0 +1,68 @@ +package codegen + +import ( + "bytes" + "fmt" + "path/filepath" + + "github.com/grafana/codejen" +) + +// RawKindJenny generates the implementation of [kindsys.Raw] for the +// provided kind declaration. +// +// gokindsdir should be the relative path to the parent directory that contains +// all generated kinds. +// +// This generator only has output for raw kinds. +func RawKindJenny(gokindsdir string, cfg *RawKindGeneratorConfig) OneToOne { + if cfg == nil { + cfg = new(RawKindGeneratorConfig) + } + if cfg.GenDirName == nil { + cfg.GenDirName = func(decl *DeclForGen) string { + return decl.Meta.Common().MachineName + } + } + + return &genRawKind{ + gokindsdir: gokindsdir, + cfg: cfg, + } +} + +type genRawKind struct { + gokindsdir string + cfg *RawKindGeneratorConfig +} + +type RawKindGeneratorConfig struct { + // GenDirName returns the name of the directory in which the file should be + // generated. Defaults to DeclForGen.Lineage().Name() if nil. + GenDirName func(*DeclForGen) string +} + +func (gen *genRawKind) JennyName() string { + return "RawKindJenny" +} + +func (gen *genRawKind) Generate(decl *DeclForGen) (*codejen.File, error) { + if !decl.IsRaw() { + return nil, nil + } + + path := filepath.Join(gen.gokindsdir, gen.cfg.GenDirName(decl), decl.Meta.Common().MachineName+"_kind_gen.go") + buf := new(bytes.Buffer) + if err := tmpls.Lookup("kind_raw.tmpl").Execute(buf, decl); err != nil { + return nil, fmt.Errorf("failed executing kind_raw template for %s: %w", path, err) + } + b, err := postprocessGoFile(genGoFile{ + path: path, + in: buf.Bytes(), + }) + if err != nil { + return nil, err + } + + return codejen.NewFile(path, b, gen), nil +} diff --git a/pkg/codegen/jenny_tstypes.go b/pkg/codegen/jenny_tstypes.go new file mode 100644 index 00000000000..110ec60ff1a --- /dev/null +++ b/pkg/codegen/jenny_tstypes.go @@ -0,0 +1,85 @@ +package codegen + +import ( + "fmt" + "path/filepath" + + "github.com/grafana/codejen" + "github.com/grafana/thema" + "github.com/grafana/thema/encoding/typescript" +) + +// TSTypesJenny creates a [OneToOne] that produces TypeScript types and +// defaults for the latest Thema schema in a structured kind's lineage. +// +// At minimum, a tskindsdir must be provided. This should be the path to the parent +// directory of the directory in which the types should be generated, relative +// to the project root. For example, if the types for a kind named "foo" +// should live at packages/grafana-schema/src/raw/foo, relpath should be "pkg/kind". +// +// This generator is a no-op for raw kinds. +func TSTypesJenny(tskindsdir string, cfg *TSTypesGeneratorConfig) OneToOne { + if cfg == nil { + cfg = new(TSTypesGeneratorConfig) + } + if cfg.GenDirName == nil { + cfg.GenDirName = func(decl *DeclForGen) string { + return decl.Meta.Common().MachineName + } + } + + return &genTSTypes{ + tskindsdir: tskindsdir, + cfg: cfg, + } +} + +// TSTypesGeneratorConfig holds configuration options for [TSTypesJenny]. +type TSTypesGeneratorConfig struct { + // GenDirName returns the name of the parent directory in which the type file + // should be generated. If nil, the DeclForGen.Lineage().Name() will be used. + GenDirName func(*DeclForGen) string + + // Version of the schema to generate. If nil, latest is generated. + Version *thema.SyntacticVersion +} + +type genTSTypes struct { + tskindsdir string + cfg *TSTypesGeneratorConfig +} + +func (gen *genTSTypes) JennyName() string { + return "TSTypesJenny" +} + +func (gen *genTSTypes) Generate(decl *DeclForGen) (*codejen.File, error) { + if decl.IsRaw() { + return nil, nil + } + var sch thema.Schema + var err error + + lin := decl.Lineage() + if gen.cfg.Version == nil { + sch = lin.Latest() + } else { + sch, err = lin.Schema(*gen.cfg.Version) + if err != nil { + return nil, fmt.Errorf("error in configured version for %s generator: %w", *gen.cfg.Version, err) + } + } + + // TODO allow using name instead of machine name in thema generator + f, err := typescript.GenerateTypes(sch, &typescript.TypeConfig{ + RootName: decl.Meta.Common().Name, + Group: decl.Meta.Common().LineageIsGroup, + }) + if err != nil { + return nil, err + } + return codejen.NewFile( + filepath.Join(gen.tskindsdir, gen.cfg.GenDirName(decl), lin.Name()+"_types.gen.ts"), + []byte(f.String()), + gen), nil +} diff --git a/pkg/codegen/jenny_tsveneerindex.go b/pkg/codegen/jenny_tsveneerindex.go new file mode 100644 index 00000000000..c213a20ddfd --- /dev/null +++ b/pkg/codegen/jenny_tsveneerindex.go @@ -0,0 +1,325 @@ +package codegen + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/errors" + "github.com/grafana/codejen" + "github.com/grafana/cuetsy/ts" + "github.com/grafana/cuetsy/ts/ast" + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/encoding/typescript" +) + +// TSVeneerIndexJenny generates an index.gen.ts file with references to all +// generated TS types. Elements with the attribute @grafana(TSVeneer="type") are +// exported from a handwritten file, rather than the raw generated types. +// +// The provided dir is the path, relative to the grafana root, to the directory +// that should contain the generated index. +// +// Implicitly depends on output patterns in TSTypesJenny. +// TODO this is wasteful; share-nothing generator model entails re-running the cuetsy gen that TSTypesJenny already did +func TSVeneerIndexJenny(dir string) ManyToOne { + return &genTSVeneerIndex{ + dir: dir, + } +} + +type genTSVeneerIndex struct { + dir string +} + +func (gen *genTSVeneerIndex) JennyName() string { + return "TSVeneerIndexJenny" +} + +func (gen *genTSVeneerIndex) Generate(decls []*DeclForGen) (*codejen.File, error) { + tsf := new(ast.File) + for _, decl := range decls { + if decl.IsRaw() { + continue + } + + sch := decl.Lineage().Latest() + f, err := typescript.GenerateTypes(sch, &typescript.TypeConfig{ + RootName: decl.Meta.Common().Name, + Group: decl.Meta.Common().LineageIsGroup, + }) + if err != nil { + return nil, fmt.Errorf("%s: %w", decl.Meta.Common().Name, err) + } + elems, err := gen.extractTSIndexVeneerElements(decl, f) + if err != nil { + return nil, fmt.Errorf("%s: %w", decl.Meta.Common().Name, err) + } + tsf.Nodes = append(tsf.Nodes, elems...) + } + + return codejen.NewFile(filepath.Join(gen.dir, "index.gen.ts"), []byte(tsf.String()), gen), nil +} + +func (gen *genTSVeneerIndex) extractTSIndexVeneerElements(decl *DeclForGen, tf *ast.File) ([]ast.Decl, error) { + lin := decl.Lineage() + sch := thema.SchemaP(lin, thema.LatestVersion(lin)) + comm := decl.Meta.Common() + + // Check the root, then walk the tree + rootv := sch.UnwrapCUE() + + var raw, custom, rawD, customD ast.Idents + + var terr errors.Error + visit := func(p cue.Path, wv cue.Value) bool { + var name string + sels := p.Selectors() + switch len(sels) { + case 0: + name = strings.Title(lin.Name()) + fallthrough + case 1: + // Only deal with subpaths that are definitions, for now + // TODO incorporate smarts about grouped lineages here + if name == "" { + if !sels[0].IsDefinition() { + return false + } + // It might seem to make sense that we'd strip replaceout the leading # here for + // definitions. However, cuetsy's tsast actually has the # still present in its + // Ident types, stripping it replaceout on the fly when stringifying. + name = sels[0].String() + } + + // Search the generated TS AST for the type and default decl nodes + pair := findDeclNode(name, tf) + if pair.T == nil { + // No generated type for this item, skip it + return false + } + + cust, perr := getCustomVeneerAttr(wv) + if perr != nil { + terr = errors.Append(terr, errors.Promote(perr, fmt.Sprintf("%s: ", p.String()))) + } + var has bool + for _, tgt := range cust { + has = has || tgt.target == "type" + } + if has { + custom = append(custom, *pair.T) + if pair.D != nil { + customD = append(customD, *pair.D) + } + } else { + raw = append(raw, *pair.T) + if pair.D != nil { + rawD = append(rawD, *pair.D) + } + } + } + + return true + } + walk(rootv, visit, nil) + + if len(errors.Errors(terr)) != 0 { + return nil, terr + } + + vpath := fmt.Sprintf("v%v", thema.LatestVersion(lin)[0]) + if decl.Meta.Common().Maturity.Less(kindsys.MaturityStable) { + vpath = "x" + } + + ret := make([]ast.Decl, 0) + if len(raw) > 0 { + ret = append(ret, ast.ExportSet{ + CommentList: []ast.Comment{ts.CommentFromString(fmt.Sprintf("Raw generated types from %s kind.", comm.Name), 80, false)}, + TypeOnly: true, + Exports: raw, + From: ast.Str{Value: fmt.Sprintf("./raw/%s/%s/%s_types.gen", comm.MachineName, vpath, comm.MachineName)}, + }) + } + if len(rawD) > 0 { + ret = append(ret, ast.ExportSet{ + CommentList: []ast.Comment{ts.CommentFromString(fmt.Sprintf("Raw generated default consts from %s kind.", lin.Name()), 80, false)}, + TypeOnly: false, + Exports: rawD, + From: ast.Str{Value: fmt.Sprintf("./raw/%s/%s/%s_types.gen", comm.MachineName, vpath, comm.MachineName)}, + }) + } + vtfile := fmt.Sprintf("./veneer/%s.types", lin.Name()) + customstr := fmt.Sprintf(`// The following exported declarations correspond to types in the %s@%s kind's +// schema with attribute @grafana(TSVeneer="type"). +// +// The handwritten file for these type and default veneers is expected to be at +// %s.ts. +// This re-export declaration enforces that the handwritten veneer file exists, +// and exports all the symbols in the list. +// +// TODO generate code such that tsc enforces type compatibility between raw and veneer decls`, + lin.Name(), thema.LatestVersion(lin), filepath.ToSlash(filepath.Join(gen.dir, vtfile))) + + customComments := []ast.Comment{{Text: customstr}} + if len(custom) > 0 { + ret = append(ret, ast.ExportSet{ + CommentList: customComments, + TypeOnly: true, + Exports: custom, + From: ast.Str{Value: vtfile}, + }) + } + if len(customD) > 0 { + ret = append(ret, ast.ExportSet{ + CommentList: customComments, + TypeOnly: false, + Exports: customD, + From: ast.Str{Value: vtfile}, + }) + } + + // TODO emit a decl in the index.gen.ts that ensures any custom veneer types are "compatible" with current version raw types + return ret, nil +} + +type declPair struct { + T, D *ast.Ident +} + +type tsVeneerAttr struct { + target string +} + +func findDeclNode(name string, tf *ast.File) declPair { + var p declPair + for _, decl := range tf.Nodes { + // Peer through export keywords + if ex, is := decl.(ast.ExportKeyword); is { + decl = ex.Decl + } + + switch x := decl.(type) { + case ast.TypeDecl: + if x.Name.Name == name { + p.T = &x.Name + } + case ast.VarDecl: + if x.Names.Idents[0].Name == "default"+name { + p.D = &x.Names.Idents[0] + } + } + } + return p +} + +func walk(v cue.Value, before func(cue.Path, cue.Value) bool, after func(cue.Path, cue.Value)) { + innerWalk(cue.MakePath(), v, before, after) +} + +func innerWalk(p cue.Path, v cue.Value, before func(cue.Path, cue.Value) bool, after func(cue.Path, cue.Value)) { + switch v.Kind() { + default: + if before != nil && !before(p, v) { + return + } + case cue.StructKind: + if before != nil && !before(p, v) { + return + } + iter, err := v.Fields(cue.All()) + if err != nil { + panic(err) + } + + for iter.Next() { + innerWalk(appendPath(p, iter.Selector()), iter.Value(), before, after) + } + if lv := v.LookupPath(cue.MakePath(cue.AnyString)); lv.Exists() { + innerWalk(appendPath(p, cue.AnyString), lv, before, after) + } + case cue.ListKind: + if before != nil && !before(p, v) { + return + } + list, err := v.List() + if err != nil { + panic(err) + } + for i := 0; list.Next(); i++ { + innerWalk(appendPath(p, cue.Index(i)), list.Value(), before, after) + } + if lv := v.LookupPath(cue.MakePath(cue.AnyIndex)); lv.Exists() { + innerWalk(appendPath(p, cue.AnyString), lv, before, after) + } + } + if after != nil { + after(p, v) + } +} + +func appendPath(p cue.Path, sel cue.Selector) cue.Path { + return cue.MakePath(append(p.Selectors(), sel)...) +} + +func getCustomVeneerAttr(v cue.Value) ([]tsVeneerAttr, error) { + var attrs []tsVeneerAttr + for _, a := range v.Attributes(cue.ValueAttr) { + if a.Name() != "grafana" { + continue + } + for i := 0; i < a.NumArgs(); i++ { + key, av := a.Arg(i) + if key != "TSVeneer" { + return nil, valError(v, "attribute 'grafana' only allows the arg 'TSVeneer'") + } + + aterr := valError(v, "@grafana(TSVeneer=\"x\") requires one or more of the following separated veneer types for x: %s", allowedTSVeneersString()) + var some bool + for _, tgt := range strings.Split(av, "|") { + some = true + if !allowedTSVeneers[tgt] { + return nil, aterr + } + attrs = append(attrs, tsVeneerAttr{ + target: tgt, + }) + } + if !some { + return nil, aterr + } + } + } + + sort.Slice(attrs, func(i, j int) bool { + return attrs[i].target < attrs[j].target + }) + + return attrs, nil +} + +var allowedTSVeneers = map[string]bool{ + "type": true, +} + +func allowedTSVeneersString() string { + var list []string + for tgt := range allowedTSVeneers { + list = append(list, tgt) + } + sort.Strings(list) + + return strings.Join(list, "|") +} + +func valError(v cue.Value, format string, args ...interface{}) error { + s := v.Source() + if s == nil { + return fmt.Errorf(format, args...) + } + return errors.Newf(s.Pos(), format, args...) +} diff --git a/pkg/codegen/pluggen.go b/pkg/codegen/pluggen.go index 55bc8d9e775..34beeac4b2d 100644 --- a/pkg/codegen/pluggen.go +++ b/pkg/codegen/pluggen.go @@ -209,7 +209,7 @@ func (pt *PluginTree) GenerateGo(path string, cfg GoGenConfig) (WriteDiffer, err for subpath, plug := range all { fullp := filepath.Join(path, subpath) if cfg.Types { - gwd, err := genGoTypes(plug, path, subpath, cfg.DocPathPrefix) + gwd, err := pgenGoTypes(plug, path, subpath, cfg.DocPathPrefix) if err != nil { return nil, fmt.Errorf("error generating go types for %s: %w", fullp, err) } @@ -218,7 +218,7 @@ func (pt *PluginTree) GenerateGo(path string, cfg GoGenConfig) (WriteDiffer, err } } if cfg.ThemaBindings { - twd, err := genThemaBindings(plug, path, subpath, cfg.DocPathPrefix) + twd, err := pgenThemaBindings(plug, path, subpath, cfg.DocPathPrefix) if err != nil { return nil, fmt.Errorf("error generating thema bindings for %s: %w", fullp, err) } @@ -231,7 +231,7 @@ func (pt *PluginTree) GenerateGo(path string, cfg GoGenConfig) (WriteDiffer, err return wd, nil } -func genGoTypes(plug pfs.PluginInfo, path, subpath, prefix string) (WriteDiffer, error) { +func pgenGoTypes(plug pfs.PluginInfo, path, subpath, prefix string) (WriteDiffer, error) { wd := NewWriteDiffer() for slotname, lin := range plug.SlotImplementations() { lowslot := strings.ToLower(slotname) @@ -287,7 +287,7 @@ func genGoTypes(plug pfs.PluginInfo, path, subpath, prefix string) (WriteDiffer, finalpath := filepath.Join(path, subpath, fmt.Sprintf("types_%s_gen.go", lowslot)) byt, err := postprocessGoFile(genGoFile{ path: finalpath, - walker: makePrefixDropper(strings.Title(lin.Name()), slotname), + walker: PrefixDropper(strings.Title(lin.Name())), in: buf.Bytes(), }) if err != nil { @@ -300,7 +300,7 @@ func genGoTypes(plug pfs.PluginInfo, path, subpath, prefix string) (WriteDiffer, return wd, nil } -func genThemaBindings(plug pfs.PluginInfo, path, subpath, prefix string) (WriteDiffer, error) { +func pgenThemaBindings(plug pfs.PluginInfo, path, subpath, prefix string) (WriteDiffer, error) { wd := NewWriteDiffer() bindings := make([]tvars_plugin_lineage_binding, 0) for slotname, lin := range plug.SlotImplementations() { diff --git a/pkg/codegen/tmpl.go b/pkg/codegen/tmpl.go index 8c20a30eba9..5b4f8212f4c 100644 --- a/pkg/codegen/tmpl.go +++ b/pkg/codegen/tmpl.go @@ -29,9 +29,12 @@ type ( LineageCUEPath string GenLicense bool } - tvars_coremodel_registry struct { - Header tvars_autogen_header - Coremodels []tplVars + tvars_kind_registry struct { + // Header tvars_autogen_header + NumRaw, NumStructured int + PackageName string + KindPackagePrefix string + Kinds []*DeclForGen } tvars_coremodel_imports struct { PackageName string diff --git a/pkg/codegen/tmpl/coremodel_registry.tmpl b/pkg/codegen/tmpl/coremodel_registry.tmpl deleted file mode 100644 index 04efd5bb3c3..00000000000 --- a/pkg/codegen/tmpl/coremodel_registry.tmpl +++ /dev/null @@ -1,58 +0,0 @@ -{{ template "autogen_header.tmpl" .Header }} -package registry - -import ( - "fmt" - "sync" - - "github.com/google/wire" - {{range .Coremodels }} - "{{ .PkgPath }}"{{end}} - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) - -// Base is a registry of coremodel.Interface. It provides two modes for accessing -// coremodels: individually via literal named methods, or as a slice returned from All(). -// -// Prefer the individual named methods for use cases where the particular coremodel(s) that -// are needed are known to the caller. For example, a dashboard linter can know that it -// specifically wants the dashboard coremodel. -// -// Prefer All() when performing operations generically across all coremodels. For example, -// a validation HTTP middleware for any coremodel-schematized object type. -type Base struct { - all []coremodel.Interface - {{- range .Coremodels }} - {{ .Name }} *{{ .Name }}.Coremodel{{end}} -} - -// type guards -var ( -{{- range .Coremodels }} - _ coremodel.Interface = &{{ .Name }}.Coremodel{}{{end}} -) - -{{range .Coremodels }} -// {{ .TitleName }} returns the {{ .Name }} coremodel. The return value is guaranteed to -// implement coremodel.Interface. -func (b *Base) {{ .TitleName }}() *{{ .Name }}.Coremodel { - return b.{{ .Name }} -} -{{end}} - -func doProvideBase(rt *thema.Runtime) *Base { - var err error - reg := &Base{} - -{{range .Coremodels }} - reg.{{ .Name }}, err = {{ .Name }}.New(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing {{ .Name }} coremodel: %s", err)) - } - reg.all = append(reg.all, reg.{{ .Name }}) -{{end}} - - return reg -} diff --git a/pkg/codegen/tmpl/kind_corestructured.tmpl b/pkg/codegen/tmpl/kind_corestructured.tmpl new file mode 100644 index 00000000000..d617ccfd2b6 --- /dev/null +++ b/pkg/codegen/tmpl/kind_corestructured.tmpl @@ -0,0 +1,95 @@ +package {{ .Meta.MachineName }} + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// rootrel is the relative path from the grafana repository root to the +// directory containing the .cue files in which this kind is declared. Necessary +// for runtime errors related to the declaration and/or lineage to provide +// a real path to the correct .cue file. +const rootrel string = "kinds/structured/{{ .Meta.MachineName }}" + +// TODO standard generated docs +type Kind struct { + lin thema.ConvergentLineage[*{{ .Meta.Name }}] + jendec vmux.Endec + valmux vmux.ValueMux[*{{ .Meta.Name }}] + decl kindsys.Decl[kindsys.CoreStructuredMeta] +} + +// type guard +var _ kindsys.Structured = &Kind{} + +// TODO standard generated docs +func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + if err != nil { + return nil, err + } + k := &Kind{ + decl: *decl, + } + + lin, err := decl.Some().BindKindLineage(rt, opts...) + if err != nil { + return nil, err + } + + // Get the thema.Schema that the meta says is in the current version (which + // codegen ensures is always the latest) + cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) + tsch, err := thema.BindType[*{{ .Meta.Name }}](cursch, &{{ .Meta.Name }}{}) + if err != nil { + // Should be unreachable, modulo bugs in the Thema->Go code generator + return nil, err + } + + k.jendec = vmux.NewJSONEndec("{{ .Meta.MachineName }}.json") + k.lin = tsch.ConvergentLineage() + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jendec) + return k, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "{{ .Meta.MachineName }}" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "{{ .Meta.MachineName }}" +} + +// TODO standard generated docs +func (k *Kind) Lineage() thema.Lineage { + return k.lin +} + +// TODO standard generated docs +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*{{ .Meta.Name }}] { + return k.lin +} + +// JSONValueMux is a version multiplexer that maps a []byte containing JSON data +// at any schematized dashboard version to an instance of {{ .Meta.Name }}. +// +// Validation and translation errors emitted from this func will identify the +// input bytes as "dashboard.json". +// +// This is a thin wrapper around Thema's [vmux.ValueMux]. +func (k *Kind) JSONValueMux(b []byte) (*{{ .Meta.Name }}, thema.TranslationLacunas, error) { + return k.valmux(b) +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Meta.Maturity +} + +// TODO standard generated docs +func (k *Kind) Meta() kindsys.CoreStructuredMeta { + return k.decl.Meta +} diff --git a/pkg/codegen/tmpl/kind_raw.tmpl b/pkg/codegen/tmpl/kind_raw.tmpl new file mode 100644 index 00000000000..479915c124e --- /dev/null +++ b/pkg/codegen/tmpl/kind_raw.tmpl @@ -0,0 +1,47 @@ +package {{ .Meta.MachineName }} + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// TODO standard generated docs +type Kind struct { + decl kindsys.Decl[kindsys.RawMeta] +} + +// type guard +var _ kindsys.Raw = &Kind{} + +// TODO standard generated docs +func NewKind() (*Kind, error) { + decl, err := kindsys.LoadCoreKind[kindsys.RawMeta]("kinds/raw/{{ .Meta.MachineName }}", nil, nil) + if err != nil { + return nil, err + } + + return &Kind{ + decl: *decl, + }, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "{{ .Meta.Name }}" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "{{ .Meta.MachineName }}" +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Meta.Maturity +} + +// TODO standard generated docs +func (k *Kind) Meta() kindsys.RawMeta { + return k.decl.Meta +} diff --git a/pkg/codegen/tmpl/kind_registry.tmpl b/pkg/codegen/tmpl/kind_registry.tmpl new file mode 100644 index 00000000000..e323903a9f9 --- /dev/null +++ b/pkg/codegen/tmpl/kind_registry.tmpl @@ -0,0 +1,60 @@ +package {{ .PackageName }} + +import ( + "fmt" + "sync" + + {{range .Kinds }} + "{{ $.KindPackagePrefix }}/{{ .Meta.MachineName }}"{{end}} + "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" +) + +// Base is a registry of kindsys.Interface. It provides two modes for accessing +// kinds: individually via literal named methods, or as a slice returned from +// an All*() method. +// +// Prefer the individual named methods for use cases where the particular kind(s) that +// are needed are known to the caller. For example, a dashboard linter can know that it +// specifically wants the dashboard kind. +// +// Prefer All*() methods when performing operations generically across all kinds. +// For example, a validation HTTP middleware for any kind-schematized object type. +type Base struct { + all []kindsys.Interface + numRaw, numStructured int + {{- range .Kinds }} + {{ .Meta.MachineName }} *{{ .Meta.MachineName }}.Kind{{end}} +} + +// type guards +var ( +{{- range .Kinds }} + _ kindsys.{{ if .IsRaw }}Raw{{ else }}Structured{{ end }} = &{{ .Meta.MachineName }}.Kind{}{{end}} +) + +{{range .Kinds }} +// {{ .Meta.Name }} returns the [kindsys.Interface] implementation for the {{ .Meta.MachineName }} kind. +func (b *Base) {{ .Meta.Name }}() *{{ .Meta.MachineName }}.Kind { + return b.{{ .Meta.MachineName }} +} +{{end}} + +func doNewBase(rt *thema.Runtime) *Base { + var err error + reg := &Base{ + numRaw: {{ .NumRaw }}, + numStructured: {{ .NumStructured }}, + } + +{{range .Kinds }} + reg.{{ .Meta.MachineName }}, err = {{ .Meta.MachineName }}.NewKind({{ if .IsCoreStructured }}rt{{ end }}) + if err != nil { + panic(fmt.Sprintf("error while initializing the {{ .Meta.MachineName }} Kind: %s", err)) + } + reg.all = append(reg.all, reg.{{ .Meta.MachineName }}) +{{end}} + + return reg +} diff --git a/pkg/codegen/util_go.go b/pkg/codegen/util_go.go index 9033e29b968..3c8b05f3a64 100644 --- a/pkg/codegen/util_go.go +++ b/pkg/codegen/util_go.go @@ -3,11 +3,13 @@ package codegen import ( "bytes" "fmt" + "go/ast" "go/format" "go/parser" "go/token" "os" "path/filepath" + "regexp" "strings" "golang.org/x/tools/go/ast/astutil" @@ -65,3 +67,84 @@ func postprocessGoFile(cfg genGoFile) ([]byte, error) { return byt, nil } + +type prefixmod struct { + str string + base string + rxp *regexp.Regexp + rxpsuff *regexp.Regexp +} + +// PrefixDropper returns an astutil.ApplyFunc that removes the provided prefix +// string when it appears as a leading sequence in type names, var names, and +// comments in a generated Go file. +func PrefixDropper(prefix string) astutil.ApplyFunc { + return (&prefixmod{ + str: prefix, + rxpsuff: regexp.MustCompile(fmt.Sprintf(`%s([a-zA-Z_]+)`, prefix)), + rxp: regexp.MustCompile(fmt.Sprintf(`%s([\s.,;-])`, prefix)), + }).applyfunc +} + +func depoint(e ast.Expr) ast.Expr { + if star, is := e.(*ast.StarExpr); is { + return star.X + } + return e +} + +func (d prefixmod) applyfunc(c *astutil.Cursor) bool { + n := c.Node() + + switch x := n.(type) { + case *ast.ValueSpec: + d.handleExpr(x.Type) + for _, id := range x.Names { + d.do(id) + } + case *ast.TypeSpec: + // Always do typespecs + d.do(x.Name) + case *ast.Field: + // Don't rename struct fields. We just want to rename type declarations, and + // field value specifications that reference those types. + d.handleExpr(x.Type) + + case *ast.CommentGroup: + for _, c := range x.List { + c.Text = d.rxpsuff.ReplaceAllString(c.Text, "$1") + if d.base != "" { + c.Text = d.rxp.ReplaceAllString(c.Text, d.base+"$1") + } + } + } + return true +} + +func (d prefixmod) handleExpr(e ast.Expr) { + // Deref a StarExpr, if there is one + expr := depoint(e) + switch x := expr.(type) { + case *ast.Ident: + d.do(x) + case *ast.ArrayType: + if id, is := depoint(x.Elt).(*ast.Ident); is { + d.do(id) + } + case *ast.MapType: + if id, is := depoint(x.Key).(*ast.Ident); is { + d.do(id) + } + if id, is := depoint(x.Value).(*ast.Ident); is { + d.do(id) + } + } +} + +func (d prefixmod) do(n *ast.Ident) { + if n.Name != d.str { + n.Name = strings.TrimPrefix(n.Name, d.str) + } else if d.base != "" { + n.Name = d.base + } +} diff --git a/pkg/coremodel/playlist/playlist_gen.go b/pkg/coremodel/playlist/playlist_gen.go deleted file mode 100644 index 79502c2b80e..00000000000 --- a/pkg/coremodel/playlist/playlist_gen.go +++ /dev/null @@ -1,136 +0,0 @@ -// This file is autogenerated. DO NOT EDIT. -// -// Generated by pkg/framework/coremodel/gen.go -// -// Derived from the Thema lineage declared in pkg/coremodel/playlist/coremodel.cue -// -// Run `make gen-cue` from repository root to regenerate. - -package playlist - -import ( - "embed" - "path/filepath" - - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) - -// Defines values for PlaylistItemType. -const ( - PlaylistItemTypeDashboardById PlaylistItemType = "dashboard_by_id" - - PlaylistItemTypeDashboardByTag PlaylistItemType = "dashboard_by_tag" - - PlaylistItemTypeDashboardByUid PlaylistItemType = "dashboard_by_uid" -) - -// Model is the Go representation of a playlist. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. -type Model struct { - // Interval sets the time between switching views in a playlist. - // FIXME: Is this based on a standardized format or what options are available? Can datemath be used? - Interval string `json:"interval"` - - // The ordered list of items that the playlist will iterate over. - // FIXME! This should not be optional, but changing it makes the godegen awkward - Items *[]PlaylistItem `json:"items,omitempty"` - - // Name of the playlist. - Name string `json:"name"` - - // Unique playlist identifier. Generated on creation, either by the - // creator of the playlist of by the application. - Uid string `json:"uid"` -} - -// PlaylistItem is the Go representation of a playlist.Item. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. -type PlaylistItem struct { - // Title is an unused property -- it will be removed in the future - Title *string `json:"title,omitempty"` - - // Type of the item. - Type PlaylistItemType `json:"type"` - - // Value depends on type and describes the playlist item. - // - // - dashboard_by_id: The value is an internal numerical identifier set by Grafana. This - // is not portable as the numerical identifier is non-deterministic between different instances. - // Will be replaced by dashboard_by_uid in the future. (deprecated) - // - dashboard_by_tag: The value is a tag which is set on any number of dashboards. All - // dashboards behind the tag will be added to the playlist. - // - dashboard_by_uid: The value is the dashboard UID - Value string `json:"value"` -} - -// Type of the item. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. -type PlaylistItemType string - -//go:embed coremodel.cue -var cueFS embed.FS - -// The current version of the coremodel schema, as declared in coremodel.cue. -// This version determines what schema version is returned from [Coremodel.CurrentSchema], -// and which schema version is used for code generation within the grafana/grafana repository. -// -// The code generator ensures that this is always the latest Thema schema version. -var currentVersion = thema.SV(0, 0) - -// Lineage returns the Thema lineage representing a Grafana playlist. -// -// The lineage is the canonical specification of the current playlist schema, -// all prior schema versions, and the mappings that allow migration between -// schema versions. -func Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "playlist"), cueFS, rt, opts...) -} - -var _ thema.LineageFactory = Lineage -var _ coremodel.Interface = &Coremodel{} - -// Coremodel contains the foundational schema declaration for playlists. -// It implements coremodel.Interface. -type Coremodel struct { - lin thema.Lineage -} - -// Lineage returns the canonical playlist Lineage. -func (c *Coremodel) Lineage() thema.Lineage { - return c.lin -} - -// CurrentSchema returns the current (latest) playlist Thema schema. -func (c *Coremodel) CurrentSchema() thema.Schema { - return thema.SchemaP(c.lin, currentVersion) -} - -// GoType returns a pointer to an empty Go struct that corresponds to -// the current Thema schema. -func (c *Coremodel) GoType() interface{} { - return &Model{} -} - -// New returns a new instance of the playlist coremodel. -// -// Note that this function does not cache, and initially loading a Thema lineage -// can be expensive. As such, the Grafana backend should prefer to access this -// coremodel through a registry (pkg/framework/coremodel/registry), which does cache. -func New(rt *thema.Runtime) (*Coremodel, error) { - lin, err := Lineage(rt) - if err != nil { - return nil, err - } - - return &Coremodel{ - lin: lin, - }, nil -} diff --git a/pkg/cuectx/ctx.go b/pkg/cuectx/ctx.go index d2865caf2ab..7b29041eb3c 100644 --- a/pkg/cuectx/ctx.go +++ b/pkg/cuectx/ctx.go @@ -5,15 +5,19 @@ package cuectx import ( + "fmt" "io/fs" "path/filepath" "testing/fstest" "cuelang.org/go/cue" + "cuelang.org/go/cue/build" "cuelang.org/go/cue/cuecontext" + "github.com/grafana/grafana" "github.com/grafana/thema" "github.com/grafana/thema/load" "github.com/grafana/thema/vmux" + "github.com/yalue/merged_fs" ) var ctx = cuecontext.New() @@ -84,16 +88,16 @@ func LoadGrafanaInstancesWithThema(path string, cueFS fs.FS, rt *thema.Runtime, return lin, nil } -// prefixWithGrafanaCUE constructs an fs.FS that merges the provided fs.FS with one -// containing grafana's cue.mod at the root. The provided prefix should be the +// prefixWithGrafanaCUE constructs an fs.FS that merges the provided fs.FS with +// the embedded FS containing Grafana's core CUE files, [grafana.CueSchemaFS]. +// The provided prefix should be the relative path from the grafana repository +// root to the directory root of the provided inputfs. // -// The returned fs.FS is suitable for passing to a CUE loader, such as -// cuelang.org/cue/load.Instances or -// github.com/grafana/thema/load.InstancesWithThema. +// The returned fs.FS is suitable for passing to a CUE loader, such as [load.InstancesWithThema]. func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) { m := fstest.MapFS{ // fstest can recognize only forward slashes. - filepath.ToSlash(filepath.Join("cue.mod", "module.cue")): &fstest.MapFile{Data: []byte(`module: "github.com/grafana/grafana"`)}, + // filepath.ToSlash(filepath.Join("cue.mod", "module.cue")): &fstest.MapFile{Data: []byte(`module: "github.com/grafana/grafana"`)}, } prefix = filepath.FromSlash(prefix) @@ -114,6 +118,81 @@ func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) { m[filepath.ToSlash(filepath.Join(prefix, path))] = &fstest.MapFile{Data: b} return nil }) + if err != nil { + return nil, err + } + return merged_fs.NewMergedFS(m, grafana.CueSchemaFS), nil +} - return m, err +// BuildGrafanaInstance wraps [load.InstancesWithThema] to load a +// [*build.Instance] corresponding to a particular path within the +// github.com/grafana/grafana CUE module, then builds that into a [cue.Value], +// checks it for errors and returns. +// +// This allows resolution of imports within the grafana or thema CUE modules to +// work correctly and consistently by relying on the embedded FS at +// [grafana.CueSchemaFS] and [thema.CueFS]. +// +// relpath should be a relative path path within [grafana.CueSchemaFS] to be +// loaded. Optionally, the caller may provide an additional fs.FS via the +// overlay parameter, which will be merged with [grafana.CueSchemaFS] at +// relpath, and loaded. +// +// pkg, if non-empty, is set as the value of +// ["cuelang.org/go/cue/load".Config.Package]. If the CUE package to be loaded +// is the same as the parent directory name, it should be omitted. +// +// NOTE this function will be removed in favor of a more generic loader +func BuildGrafanaInstance(relpath string, pkg string, ctx *cue.Context, overlay fs.FS) (cue.Value, error) { + // notes about how this crap needs to work + // + // Within grafana/grafana, need: + // - pass in an fs.FS that, in its root, contains the .cue files to load + // - has no cue.mod + // - gets prefixed with the appropriate path within grafana/grafana + // - and merged with all the other .cue files from grafana/grafana + if ctx == nil { + ctx = GrafanaCUEContext() + } + relpath = filepath.ToSlash(relpath) + + var v cue.Value + var f fs.FS = grafana.CueSchemaFS + var err error + if overlay != nil { + f, err = prefixWithGrafanaCUE(relpath, overlay) + if err != nil { + return v, err + } + } + + var bi *build.Instance + if pkg != "" { + bi, err = load.InstancesWithThema(f, relpath, load.Package(pkg)) + } else { + bi, err = load.InstancesWithThema(f, relpath) + } + if err != nil { + return v, err + } + + v = ctx.BuildInstance(bi) + if v.Err() != nil { + return v, fmt.Errorf("%s not a valid CUE instance: %w", relpath, v.Err()) + } + return v, nil +} + +// TODO docs +// NOTE this function will be removed in favor of a more generic loader +func LoadInstanceWithGrafana(ifs fs.FS, prefix string) (*build.Instance, error) { + // notes about how this crap needs to work + // + // Need a prefixing instance loader that: + // - can take multiple fs.FS, each one representing a CUE module (nesting?) + // - reconcile at most one of the provided fs with cwd + // - behavior must differ depending on whether cwd is in a cue module + // - behavior should(?) be controllable depending on + + panic("TODO") } diff --git a/pkg/framework/coremodel/gen.go b/pkg/framework/coremodel/gen.go index c56b5983353..fb84467c031 100644 --- a/pkg/framework/coremodel/gen.go +++ b/pkg/framework/coremodel/gen.go @@ -7,21 +7,12 @@ package main import ( "fmt" "os" - "path" "path/filepath" - "sort" - "strings" - "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" - "cuelang.org/go/cue/errors" "cuelang.org/go/cue/load" "github.com/grafana/cuetsy" - "github.com/grafana/cuetsy/ts" - "github.com/grafana/cuetsy/ts/ast" gcgen "github.com/grafana/grafana/pkg/codegen" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/thema" ) const sep = string(filepath.Separator) @@ -45,74 +36,11 @@ func init() { // Generate Go and Typescript implementations for all coremodels, and populate the // coremodel static registry. func main() { - rt := cuectx.GrafanaThemaRuntime() if len(os.Args) > 1 { fmt.Fprintf(os.Stderr, "coremodel code generator does not currently accept any arguments\n, got %q", os.Args) os.Exit(1) } - - items, err := os.ReadDir(cmroot) - if err != nil { - fmt.Fprintf(os.Stderr, "could not read coremodels parent dir %s: %s\n", cmroot, err) - os.Exit(1) - } - - var lins []*gcgen.CoremodelDeclaration - for _, item := range items { - if item.IsDir() { - lin, err := gcgen.ExtractLineage(filepath.Join(cmroot, item.Name(), "coremodel.cue"), rt) - if err != nil { - fmt.Fprintf(os.Stderr, "could not process coremodel dir %s: %s\n", filepath.Join(cmroot, item.Name()), err) - os.Exit(1) - } - - lins = append(lins, lin) - } - } - sort.Slice(lins, func(i, j int) bool { - return lins[i].Lineage.Name() < lins[j].Lineage.Name() - }) - - // The typescript veneer index.gen.ts file, which we'll build up over time - // from the exported types. - tsvidx := new(ast.File) wd := gcgen.NewWriteDiffer() - for _, ls := range lins { - gofiles, err := ls.GenerateGoCoremodel(filepath.Join(cmroot, ls.Lineage.Name())) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to generate Go for %s: %s\n", ls.Lineage.Name(), err) - os.Exit(1) - } - wd.Merge(gofiles) - - // Only generate TS for API types - if ls.IsAPIType { - tsf, err := ls.GenerateTypescriptCoremodel() - if err != nil { - fmt.Fprintf(os.Stderr, "error generating TypeScript for %s: %s\n", ls.Lineage.Name(), err) - os.Exit(1) - } - tsf.Doc = mkTSHeader(ls) - wd[filepath.FromSlash(filepath.Join(tsroot, rawTSGenPath(ls)))] = []byte(tsf.String()) - - decls, err := extractTSIndexVeneerElements(ls, tsf) - if err != nil { - fmt.Fprintf(os.Stderr, "error generating TypeScript veneer for %s: %s\n", ls.Lineage.Name(), errors.Details(err, nil)) - os.Exit(1) - } - tsvidx.Nodes = append(tsvidx.Nodes, decls...) - } - } - - tsvidx.Doc = mkTSHeader(nil) - wd[filepath.Join(tsroot, "index.gen.ts")] = []byte(tsvidx.String()) - - regfiles, err := gcgen.GenerateCoremodelRegistry(filepath.Join(groot, "pkg", "framework", "coremodel", "registry", "registry_gen.go"), lins) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to generate coremodel registry: %s\n", err) - os.Exit(1) - } - wd.Merge(regfiles) // TODO generating these is here temporarily until we make a more permanent home wdsh, err := genSharedSchemas(groot) @@ -137,25 +65,6 @@ func main() { } } -// generates the path relative to packages/grafana-schema/src at which the raw -// type definitions should be exported for the latest schema of this type -func rawTSGenPath(cm *gcgen.CoremodelDeclaration) string { - return fmt.Sprintf("raw/%s/%s/%s.gen.ts", cm.Lineage.Name(), cm.PathVersion(), cm.Lineage.Name()) -} - -func mkTSHeader(cm *gcgen.CoremodelDeclaration) *ast.Comment { - v := gcgen.HeaderVars{ - GeneratorPath: "pkg/framework/coremodel/gen.go", - } - if cm != nil { - v.LineagePath = cm.RelativePath - } - v.GeneratorPath = "pkg/framework/coremodel/gen.go" - return &ast.Comment{ - Text: strings.TrimSpace(gcgen.GenGrafanaHeader(v)), - } -} - func genSharedSchemas(groot string) (gcgen.WriteDiffer, error) { abspath := filepath.Join(groot, "packages", "grafana-schema", "src", "schema") cfg := &load.Config{ @@ -183,7 +92,7 @@ func genSharedSchemas(groot string) (gcgen.WriteDiffer, error) { } wd := gcgen.NewWriteDiffer() - wd[filepath.Join(abspath, "mudball.gen.ts")] = append([]byte(`//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + wd[filepath.Join(abspath, "mudball.gen.ts")] = append([]byte(`//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~ // This file is autogenerated. DO NOT EDIT. // // To regenerate, run "make gen-cue" from the repository root. @@ -191,259 +100,3 @@ func genSharedSchemas(groot string) (gcgen.WriteDiffer, error) { `), b...) return wd, nil } - -// TODO make this more generic and reusable -func extractTSIndexVeneerElements(cm *gcgen.CoremodelDeclaration, tf *ast.File) ([]ast.Decl, error) { - lin := cm.Lineage - sch := thema.SchemaP(lin, thema.LatestVersion(lin)) - - // Check the root, then walk the tree - rootv := sch.UnwrapCUE() - - var raw, custom, rawD, customD ast.Idents - - var terr errors.Error - visit := func(p cue.Path, wv cue.Value) bool { - var name string - sels := p.Selectors() - switch len(sels) { - case 0: - name = strings.Title(cm.Lineage.Name()) - fallthrough - case 1: - // Only deal with subpaths that are definitions, for now - // TODO incorporate smarts about grouped lineages here - if name == "" { - if !sels[0].IsDefinition() { - return false - } - // It might seem to make sense that we'd strip out the leading # here for - // definitions. However, cuetsy's tsast actually has the # still present in its - // Ident types, stripping it out on the fly when stringifying. - name = sels[0].String() - } - - // Search the generated TS AST for the type and default decl nodes - pair := findDeclNode(name, tf) - if pair.T == nil { - // No generated type for this item, skip it - return false - } - - cust, perr := getCustomVeneerAttr(wv) - if perr != nil { - terr = errors.Append(terr, errors.Promote(perr, fmt.Sprintf("%s: ", p.String()))) - } - var has bool - for _, tgt := range cust { - has = has || tgt.target == "type" - } - if has { - custom = append(custom, *pair.T) - if pair.D != nil { - customD = append(customD, *pair.D) - } - } else { - raw = append(raw, *pair.T) - if pair.D != nil { - rawD = append(rawD, *pair.D) - } - } - } - - return true - } - walk(rootv, visit, nil) - - if len(errors.Errors(terr)) != 0 { - return nil, terr - } - - ret := make([]ast.Decl, 0) - if len(raw) > 0 { - ret = append(ret, ast.ExportSet{ - CommentList: []ast.Comment{ts.CommentFromString(fmt.Sprintf("Raw generated types from %s entity type.", cm.Lineage.Name()), 80, false)}, - TypeOnly: true, - Exports: raw, - From: ast.Str{Value: fmt.Sprintf("./raw/%s/%s/%s.gen", cm.Lineage.Name(), cm.PathVersion(), cm.Lineage.Name())}, - }) - } - if len(rawD) > 0 { - ret = append(ret, ast.ExportSet{ - CommentList: []ast.Comment{ts.CommentFromString(fmt.Sprintf("Raw generated default consts from %s entity type.", cm.Lineage.Name()), 80, false)}, - TypeOnly: false, - Exports: rawD, - From: ast.Str{Value: fmt.Sprintf("./raw/%s/%s/%s.gen", cm.Lineage.Name(), cm.PathVersion(), cm.Lineage.Name())}, - }) - } - vtfile := fmt.Sprintf("./veneer/%s.types", cm.Lineage.Name()) - customstr := fmt.Sprintf(`// The following exported declarations correspond to types in the %s@%s schema with -// attribute @grafana(TSVeneer="type"). (lineage declared in file: %s) -// -// The handwritten file for these type and default veneers is expected to be at -// %s.ts. -// This re-export declaration enforces that the handwritten veneer file exists, -// and exports all the symbols in the list. -// -// TODO generate code such that tsc enforces type compatibility between raw and veneer decls`, - cm.Lineage.Name(), thema.LatestVersion(cm.Lineage), cm.RelativePath, filepath.Clean(path.Join("packages", "grafana-schema", "src", vtfile))) - - customComments := []ast.Comment{{Text: customstr}} - if len(custom) > 0 { - ret = append(ret, ast.ExportSet{ - CommentList: customComments, - TypeOnly: true, - Exports: custom, - From: ast.Str{Value: vtfile}, - }) - } - if len(customD) > 0 { - ret = append(ret, ast.ExportSet{ - CommentList: customComments, - TypeOnly: false, - Exports: customD, - From: ast.Str{Value: vtfile}, - }) - } - - // TODO emit a decl in the index.gen.ts that ensures any custom veneer types are "compatible" with current version raw types - return ret, nil -} - -type declPair struct { - T, D *ast.Ident -} - -func findDeclNode(name string, tf *ast.File) declPair { - var p declPair - for _, decl := range tf.Nodes { - // Peer through export keywords - if ex, is := decl.(ast.ExportKeyword); is { - decl = ex.Decl - } - - switch x := decl.(type) { - case ast.TypeDecl: - if x.Name.Name == name { - p.T = &x.Name - } - case ast.VarDecl: - if x.Names.Idents[0].Name == "default"+name { - p.D = &x.Names.Idents[0] - } - } - } - return p -} - -type tsVeneerAttr struct { - target string -} - -func walk(v cue.Value, before func(cue.Path, cue.Value) bool, after func(cue.Path, cue.Value)) { - innerWalk(cue.MakePath(), v, before, after) -} - -func innerWalk(p cue.Path, v cue.Value, before func(cue.Path, cue.Value) bool, after func(cue.Path, cue.Value)) { - // switch v.IncompleteKind() { - switch v.Kind() { - default: - if before != nil && !before(p, v) { - return - } - case cue.StructKind: - if before != nil && !before(p, v) { - return - } - iter, err := v.Fields(cue.All()) - if err != nil { - panic(err) - } - - for iter.Next() { - innerWalk(appendPath(p, iter.Selector()), iter.Value(), before, after) - } - if lv := v.LookupPath(cue.MakePath(cue.AnyString)); lv.Exists() { - innerWalk(appendPath(p, cue.AnyString), lv, before, after) - } - case cue.ListKind: - if before != nil && !before(p, v) { - return - } - list, err := v.List() - if err != nil { - panic(err) - } - for i := 0; list.Next(); i++ { - innerWalk(appendPath(p, cue.Index(i)), list.Value(), before, after) - } - if lv := v.LookupPath(cue.MakePath(cue.AnyIndex)); lv.Exists() { - innerWalk(appendPath(p, cue.AnyString), lv, before, after) - } - } - if after != nil { - after(p, v) - } -} - -func appendPath(p cue.Path, sel cue.Selector) cue.Path { - return cue.MakePath(append(p.Selectors(), sel)...) -} - -var allowedTSVeneers = map[string]bool{ - "type": true, -} - -func allowedTSVeneersString() string { - var list []string - for tgt := range allowedTSVeneers { - list = append(list, tgt) - } - sort.Strings(list) - - return strings.Join(list, "|") -} - -func getCustomVeneerAttr(v cue.Value) ([]tsVeneerAttr, error) { - var attrs []tsVeneerAttr - for _, a := range v.Attributes(cue.ValueAttr) { - if a.Name() != "grafana" { - continue - } - for i := 0; i < a.NumArgs(); i++ { - key, av := a.Arg(i) - if key != "TSVeneer" { - return nil, valError(v, "attribute 'grafana' only allows the arg 'TSVeneer'") - } - - aterr := valError(v, "@grafana(TSVeneer=\"x\") requires one or more of the following separated veneer types for x: %s", allowedTSVeneersString()) - var some bool - for _, tgt := range strings.Split(av, "|") { - some = true - if !allowedTSVeneers[tgt] { - return nil, aterr - } - attrs = append(attrs, tsVeneerAttr{ - target: tgt, - }) - } - if !some { - return nil, aterr - } - } - } - - sort.Slice(attrs, func(i, j int) bool { - return attrs[i].target < attrs[j].target - }) - - return attrs, nil -} - -func valError(v cue.Value, format string, args ...interface{}) error { - s := v.Source() - if s == nil { - return fmt.Errorf(format, args...) - } - return errors.Newf(s.Pos(), format, args...) -} diff --git a/pkg/framework/coremodel/registry/assignability_test.go b/pkg/framework/coremodel/registry/assignability_test.go deleted file mode 100644 index 8d83e6284d7..00000000000 --- a/pkg/framework/coremodel/registry/assignability_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package registry_test - -import ( - "testing" - - "github.com/grafana/grafana/pkg/framework/coremodel/registry" - "github.com/grafana/thema" -) - -func TestSchemaAssignability(t *testing.T) { - reg := registry.NewBase(nil) - - for _, cm := range reg.All() { - tcm := cm - t.Run(tcm.Lineage().Name(), func(t *testing.T) { - err := thema.AssignableTo(tcm.CurrentSchema(), tcm.GoType()) - if err != nil { - t.Fatal(err) - } - }) - } -} diff --git a/pkg/framework/coremodel/registry/provide.go b/pkg/framework/coremodel/registry/provide.go deleted file mode 100644 index e64e314987f..00000000000 --- a/pkg/framework/coremodel/registry/provide.go +++ /dev/null @@ -1,48 +0,0 @@ -package registry - -import ( - "sync" - - "github.com/google/wire" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) - -// CoremodelSet contains all of the wire-style providers related to coremodels. -var CoremodelSet = wire.NewSet( - NewBase, -) - -var ( - baseOnce sync.Once - defaultBase *Base -) - -// NewBase provides a registry of all coremodels, without any composition of -// plugin-defined schemas. -// -// All calling code within grafana/grafana is expected to use Grafana's -// singleton [thema.Runtime], returned from [cuectx.GrafanaThemaRuntime]. If nil -// is passed, the singleton will be used. -func NewBase(rt *thema.Runtime) *Base { - allrt := cuectx.GrafanaThemaRuntime() - if rt == nil || rt == allrt { - baseOnce.Do(func() { - defaultBase = doProvideBase(allrt) - }) - return defaultBase - } - - return doProvideBase(rt) -} - -// All returns a slice of all registered coremodels. -// -// Prefer this method when operating generically across all coremodels. -// -// The returned slice is sorted lexicographically by coremodel name. It should -// not be modified. -func (b *Base) All() []coremodel.Interface { - return b.all -} diff --git a/pkg/framework/coremodel/registry/registry_gen.go b/pkg/framework/coremodel/registry/registry_gen.go deleted file mode 100644 index 7daa559cb58..00000000000 --- a/pkg/framework/coremodel/registry/registry_gen.go +++ /dev/null @@ -1,83 +0,0 @@ -// This file is autogenerated. DO NOT EDIT. -// -// Generated by pkg/framework/coremodel/gen.go -// -// Run `make gen-cue` from repository root to regenerate. - -package registry - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/coremodel/dashboard" - "github.com/grafana/grafana/pkg/coremodel/playlist" - "github.com/grafana/grafana/pkg/coremodel/pluginmeta" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) - -// Base is a registry of coremodel.Interface. It provides two modes for accessing -// coremodels: individually via literal named methods, or as a slice returned from All(). -// -// Prefer the individual named methods for use cases where the particular coremodel(s) that -// are needed are known to the caller. For example, a dashboard linter can know that it -// specifically wants the dashboard coremodel. -// -// Prefer All() when performing operations generically across all coremodels. For example, -// a validation HTTP middleware for any coremodel-schematized object type. -type Base struct { - all []coremodel.Interface - dashboard *dashboard.Coremodel - playlist *playlist.Coremodel - pluginmeta *pluginmeta.Coremodel -} - -// type guards -var ( - _ coremodel.Interface = &dashboard.Coremodel{} - _ coremodel.Interface = &playlist.Coremodel{} - _ coremodel.Interface = &pluginmeta.Coremodel{} -) - -// Dashboard returns the dashboard coremodel. The return value is guaranteed to -// implement coremodel.Interface. -func (b *Base) Dashboard() *dashboard.Coremodel { - return b.dashboard -} - -// Playlist returns the playlist coremodel. The return value is guaranteed to -// implement coremodel.Interface. -func (b *Base) Playlist() *playlist.Coremodel { - return b.playlist -} - -// Pluginmeta returns the pluginmeta coremodel. The return value is guaranteed to -// implement coremodel.Interface. -func (b *Base) Pluginmeta() *pluginmeta.Coremodel { - return b.pluginmeta -} - -func doProvideBase(rt *thema.Runtime) *Base { - var err error - reg := &Base{} - - reg.dashboard, err = dashboard.New(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing dashboard coremodel: %s", err)) - } - reg.all = append(reg.all, reg.dashboard) - - reg.playlist, err = playlist.New(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing playlist coremodel: %s", err)) - } - reg.all = append(reg.all, reg.playlist) - - reg.pluginmeta, err = pluginmeta.New(rt) - if err != nil { - panic(fmt.Sprintf("error while initializing pluginmeta coremodel: %s", err)) - } - reg.all = append(reg.all, reg.pluginmeta) - - return reg -} diff --git a/pkg/framework/coremodel/slot/doc.go b/pkg/framework/coremodel/slot/doc.go deleted file mode 100644 index 6c1a39f1f62..00000000000 --- a/pkg/framework/coremodel/slot/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package Slot exposes Grafana's coremodel composition Slot definitions for use in Go. -package slot diff --git a/pkg/coremodel/dashboard/addenda.go b/pkg/kinds/dashboard/addenda.go similarity index 100% rename from pkg/coremodel/dashboard/addenda.go rename to pkg/kinds/dashboard/addenda.go diff --git a/pkg/kinds/dashboard/dashboard_kind_gen.go b/pkg/kinds/dashboard/dashboard_kind_gen.go new file mode 100644 index 00000000000..6bdb47dacf3 --- /dev/null +++ b/pkg/kinds/dashboard/dashboard_kind_gen.go @@ -0,0 +1,104 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// CoreStructuredKindJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package dashboard + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// rootrel is the relative path from the grafana repository root to the +// directory containing the .cue files in which this kind is declared. Necessary +// for runtime errors related to the declaration and/or lineage to provide +// a real path to the correct .cue file. +const rootrel string = "kinds/structured/dashboard" + +// TODO standard generated docs +type Kind struct { + lin thema.ConvergentLineage[*Dashboard] + jendec vmux.Endec + valmux vmux.ValueMux[*Dashboard] + decl kindsys.Decl[kindsys.CoreStructuredMeta] +} + +// type guard +var _ kindsys.Structured = &Kind{} + +// TODO standard generated docs +func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + if err != nil { + return nil, err + } + k := &Kind{ + decl: *decl, + } + + lin, err := decl.Some().BindKindLineage(rt, opts...) + if err != nil { + return nil, err + } + + // Get the thema.Schema that the meta says is in the current version (which + // codegen ensures is always the latest) + cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) + tsch, err := thema.BindType[*Dashboard](cursch, &Dashboard{}) + if err != nil { + // Should be unreachable, modulo bugs in the Thema->Go code generator + return nil, err + } + + k.jendec = vmux.NewJSONEndec("dashboard.json") + k.lin = tsch.ConvergentLineage() + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jendec) + return k, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "dashboard" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "dashboard" +} + +// TODO standard generated docs +func (k *Kind) Lineage() thema.Lineage { + return k.lin +} + +// TODO standard generated docs +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Dashboard] { + return k.lin +} + +// JSONValueMux is a version multiplexer that maps a []byte containing JSON data +// at any schematized dashboard version to an instance of Dashboard. +// +// Validation and translation errors emitted from this func will identify the +// input bytes as "dashboard.json". +// +// This is a thin wrapper around Thema's [vmux.ValueMux]. +func (k *Kind) JSONValueMux(b []byte) (*Dashboard, thema.TranslationLacunas, error) { + return k.valmux(b) +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Meta.Maturity +} + +// TODO standard generated docs +func (k *Kind) Meta() kindsys.CoreStructuredMeta { + return k.decl.Meta +} diff --git a/pkg/coremodel/dashboard/dashboard_gen.go b/pkg/kinds/dashboard/dashboard_types_gen.go similarity index 62% rename from pkg/coremodel/dashboard/dashboard_gen.go rename to pkg/kinds/dashboard/dashboard_types_gen.go index 68aab0d37fe..e4d63c1a16b 100644 --- a/pkg/coremodel/dashboard/dashboard_gen.go +++ b/pkg/kinds/dashboard/dashboard_types_gen.go @@ -1,22 +1,14 @@ -// This file is autogenerated. DO NOT EDIT. +// THIS FILE IS GENERATED. EDITING IS FUTILE. // -// Generated by pkg/framework/coremodel/gen.go +// Generated by: +// kinds/gen.go +// Using jennies: +// GoTypesJenny // -// Derived from the Thema lineage declared in pkg/coremodel/dashboard/coremodel.cue -// -// Run `make gen-cue` from repository root to regenerate. +// Run 'make gen-cue' from repository root to regenerate. package dashboard -import ( - "embed" - "path/filepath" - - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) - // Defines values for GraphTooltip. const ( GraphTooltipN0 GraphTooltip = 0 @@ -207,11 +199,8 @@ const ( VariableTypeTextbox VariableType = "textbox" ) -// Model is the Go representation of a dashboard. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. -type Model struct { +// Dashboard defines model for dashboard. +type Dashboard struct { Annotations *struct { // TODO docs List []AnnotationQuery `json:"list"` @@ -298,29 +287,17 @@ type Model struct { WeekStart *string `json:"weekStart,omitempty"` } -// GraphTooltip is the Go representation of a Model.GraphTooltip. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// GraphTooltip defines model for Dashboard.GraphTooltip. type GraphTooltip int // Theme of dashboard. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Style string // Timezone of dashboard, -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Timezone string // TODO docs // FROM: AnnotationQuery in grafana-data/src/types/annotations.ts -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type AnnotationQuery struct { BuiltIn int `json:"builtIn"` @@ -343,16 +320,15 @@ type AnnotationQuery struct { Name *string `json:"name,omitempty"` // Query for annotation data. - RawQuery *string `json:"rawQuery,omitempty"` - ShowIn int `json:"showIn"` - Target *AnnotationTarget `json:"target,omitempty"` - Type string `json:"type"` + RawQuery *string `json:"rawQuery,omitempty"` + ShowIn int `json:"showIn"` + + // TODO docs + Target *AnnotationTarget `json:"target,omitempty"` + Type string `json:"type"` } -// AnnotationTarget is the Go representation of a dashboard.AnnotationTarget. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// TODO docs type AnnotationTarget struct { Limit int64 `json:"limit"` MatchAny bool `json:"matchAny"` @@ -363,16 +339,10 @@ type AnnotationTarget struct { // 0 for no shared crosshair or tooltip (default). // 1 for shared crosshair. // 2 for shared crosshair AND shared tooltip. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type DashboardCursorSync int // FROM public/app/features/dashboard/state/Models.ts - ish // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type DashboardLink struct { AsDropdown bool `json:"asDropdown"` Icon *string `json:"icon,omitempty"` @@ -386,25 +356,16 @@ type DashboardLink struct { Url *string `json:"url,omitempty"` } -// DashboardLinkType is the Go representation of a DashboardLink.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// DashboardLinkType defines model for DashboardLink.Type. type DashboardLinkType string -// DynamicConfigValue is the Go representation of a dashboard.DynamicConfigValue. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// DynamicConfigValue defines model for dashboard.DynamicConfigValue. type DynamicConfigValue struct { Id string `json:"id"` Value *interface{} `json:"value,omitempty"` } // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type FieldColor struct { // Stores the fixed color value if mode is fixed FixedColor *string `json:"fixedColor,omitempty"` @@ -417,21 +378,12 @@ type FieldColor struct { } // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type FieldColorModeId string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type FieldColorSeriesByMode string -// FieldConfig is the Go representation of a dashboard.FieldConfig. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// FieldConfig defines model for dashboard.FieldConfig. type FieldConfig struct { // TODO docs Color *FieldColor `json:"color,omitempty"` @@ -467,7 +419,7 @@ type FieldConfig struct { // Alternative to empty string NoValue *string `json:"noValue,omitempty"` - // An explict path to the field in the datasource. When the frame meta includes a path, + // An explicit path to the field in the datasource. When the frame meta includes a path, // This will default to `${frame.meta.path}/${field.name} // // When defined, this value can be used as an identifier within the datasource scope, and @@ -482,10 +434,7 @@ type FieldConfig struct { Writeable *bool `json:"writeable,omitempty"` } -// FieldConfigSource is the Go representation of a dashboard.FieldConfigSource. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// FieldConfigSource defines model for dashboard.FieldConfigSource. type FieldConfigSource struct { Defaults struct { // TODO docs @@ -522,7 +471,7 @@ type FieldConfigSource struct { // Alternative to empty string NoValue *string `json:"noValue,omitempty"` - // An explict path to the field in the datasource. When the frame meta includes a path, + // An explicit path to the field in the datasource. When the frame meta includes a path, // This will default to `${frame.meta.path}/${field.name} // // When defined, this value can be used as an identifier within the datasource scope, and @@ -548,25 +497,16 @@ type FieldConfigSource struct { } `json:"overrides"` } -// GraphPanel is the Go representation of a dashboard.GraphPanel. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// GraphPanel defines model for dashboard.GraphPanel. type GraphPanel struct { // Support for legacy graph and heatmap panels. Type GraphPanelType `json:"type"` } // Support for legacy graph and heatmap panels. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type GraphPanelType string -// GridPos is the Go representation of a dashboard.GridPos. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// GridPos defines model for dashboard.GridPos. type GridPos struct { // Panel H int `json:"h"` @@ -584,41 +524,26 @@ type GridPos struct { Y int `json:"y"` } -// HeatmapPanel is the Go representation of a dashboard.HeatmapPanel. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// HeatmapPanel defines model for dashboard.HeatmapPanel. type HeatmapPanel struct { Type HeatmapPanelType `json:"type"` } -// HeatmapPanelType is the Go representation of a HeatmapPanel.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// HeatmapPanelType defines model for HeatmapPanel.Type. type HeatmapPanelType string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type MappingType string -// MatcherConfig is the Go representation of a dashboard.MatcherConfig. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// MatcherConfig defines model for dashboard.MatcherConfig. type MatcherConfig struct { Id string `json:"id"` Options *interface{} `json:"options,omitempty"` } -// Model panels. Panels are canonically defined inline +// Dashboard panels. Panels are canonically defined inline // because they share a version timeline with the dashboard // schema; they do not evolve independently. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Panel struct { // The datasource used in all targets. Datasource *struct { @@ -664,7 +589,7 @@ type Panel struct { // Alternative to empty string NoValue *string `json:"noValue,omitempty"` - // An explict path to the field in the datasource. When the frame meta includes a path, + // An explicit path to the field in the datasource. When the frame meta includes a path, // This will default to `${frame.meta.path}/${field.name} // // When defined, this value can be used as an identifier within the datasource scope, and @@ -755,15 +680,9 @@ type Panel struct { // Direction to repeat in if 'repeat' is set. // "h" for horizontal, "v" for vertical. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type PanelRepeatDirection string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type RangeMap struct { Options struct { // to and from are `number | null` in current ts, really not sure what to do @@ -779,16 +698,10 @@ type RangeMap struct { Type RangeMapType `json:"type"` } -// RangeMapType is the Go representation of a RangeMap.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// RangeMapType defines model for RangeMap.Type. type RangeMapType string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type RegexMap struct { Options struct { Pattern string `json:"pattern"` @@ -802,16 +715,10 @@ type RegexMap struct { Type RegexMapType `json:"type"` } -// RegexMapType is the Go representation of a RegexMap.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// RegexMapType defines model for RegexMap.Type. type RegexMapType string // Row panel -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type RowPanel struct { Collapsed bool `json:"collapsed"` @@ -830,16 +737,10 @@ type RowPanel struct { Type RowPanelType `json:"type"` } -// RowPanelType is the Go representation of a RowPanel.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// RowPanelType defines model for RowPanel.Type. type RowPanelType string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type SpecialValueMap struct { Options struct { Match SpecialValueMapOptionsMatch `json:"match"` @@ -854,40 +755,25 @@ type SpecialValueMap struct { Type SpecialValueMapType `json:"type"` } -// SpecialValueMapOptionsMatch is the Go representation of a SpecialValueMap.Options.Match. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// SpecialValueMapOptionsMatch defines model for SpecialValueMap.Options.Match. type SpecialValueMapOptionsMatch string -// SpecialValueMapType is the Go representation of a SpecialValueMap.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// SpecialValueMapType defines model for SpecialValueMap.Type. type SpecialValueMapType string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type SpecialValueMatch string // Schema for panel targets is specified by datasource // plugins. We use a placeholder definition, which the Go // schema loader either left open/as-is with the Base -// variant of the Model and Panel families, or filled +// variant of the Dashboard and Panel families, or filled // with types derived from plugins in the Instance variant. // When working directly from CUE, importers can extend this // type directly to achieve the same effect. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Target map[string]interface{} // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Threshold struct { // TODO docs Color string `json:"color"` @@ -902,10 +788,7 @@ type Threshold struct { Value *float32 `json:"value,omitempty"` } -// ThresholdsConfig is the Go representation of a dashboard.ThresholdsConfig. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// ThresholdsConfig defines model for dashboard.ThresholdsConfig. type ThresholdsConfig struct { Mode ThresholdsConfigMode `json:"mode"` @@ -925,53 +808,32 @@ type ThresholdsConfig struct { } `json:"steps"` } -// ThresholdsConfigMode is the Go representation of a ThresholdsConfig.Mode. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// ThresholdsConfigMode defines model for ThresholdsConfig.Mode. type ThresholdsConfigMode string -// ThresholdsMode is the Go representation of a dashboard.ThresholdsMode. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// ThresholdsMode defines model for dashboard.ThresholdsMode. type ThresholdsMode string // TODO docs // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Transformation struct { Id string `json:"id"` Options map[string]interface{} `json:"options"` } // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type ValueMap struct { Options map[string]interface{} `json:"options"` Type ValueMapType `json:"type"` } -// ValueMapType is the Go representation of a ValueMap.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// ValueMapType defines model for ValueMap.Type. type ValueMapType string // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type ValueMapping interface{} // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type ValueMappingResult struct { Color *string `json:"color,omitempty"` Icon *string `json:"icon,omitempty"` @@ -983,85 +845,16 @@ type ValueMappingResult struct { // TODO docs // TODO what about what's in public/app/features/types.ts? // TODO there appear to be a lot of different kinds of [template] vars here? if so need a disjunction -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type VariableModel struct { Label *string `json:"label,omitempty"` Name string `json:"name"` Type VariableModelType `json:"type"` } -// VariableModelType is the Go representation of a VariableModel.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// VariableModelType defines model for VariableModel.Type. type VariableModelType string // FROM: packages/grafana-data/src/types/templateVars.ts // TODO docs // TODO this implies some wider pattern/discriminated union, probably? -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type VariableType string - -//go:embed coremodel.cue -var cueFS embed.FS - -// The current version of the coremodel schema, as declared in coremodel.cue. -// This version determines what schema version is returned from [Coremodel.CurrentSchema], -// and which schema version is used for code generation within the grafana/grafana repository. -// -// The code generator ensures that this is always the latest Thema schema version. -var currentVersion = thema.SV(0, 0) - -// Lineage returns the Thema lineage representing a Grafana dashboard. -// -// The lineage is the canonical specification of the current dashboard schema, -// all prior schema versions, and the mappings that allow migration between -// schema versions. -func Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "dashboard"), cueFS, rt, opts...) -} - -var _ thema.LineageFactory = Lineage -var _ coremodel.Interface = &Coremodel{} - -// Coremodel contains the foundational schema declaration for dashboards. -// It implements coremodel.Interface. -type Coremodel struct { - lin thema.Lineage -} - -// Lineage returns the canonical dashboard Lineage. -func (c *Coremodel) Lineage() thema.Lineage { - return c.lin -} - -// CurrentSchema returns the current (latest) dashboard Thema schema. -func (c *Coremodel) CurrentSchema() thema.Schema { - return thema.SchemaP(c.lin, currentVersion) -} - -// GoType returns a pointer to an empty Go struct that corresponds to -// the current Thema schema. -func (c *Coremodel) GoType() interface{} { - return &Model{} -} - -// New returns a new instance of the dashboard coremodel. -// -// Note that this function does not cache, and initially loading a Thema lineage -// can be expensive. As such, the Grafana backend should prefer to access this -// coremodel through a registry (pkg/framework/coremodel/registry), which does cache. -func New(rt *thema.Runtime) (*Coremodel, error) { - lin, err := Lineage(rt) - if err != nil { - return nil, err - } - - return &Coremodel{ - lin: lin, - }, nil -} diff --git a/pkg/coremodel/dashboard/dashboards_test.go b/pkg/kinds/dashboard/dashboards_test.go similarity index 91% rename from pkg/coremodel/dashboard/dashboards_test.go rename to pkg/kinds/dashboard/dashboards_test.go index ba4fa4a7fc4..0115ae62fbd 100644 --- a/pkg/coremodel/dashboard/dashboards_test.go +++ b/pkg/kinds/dashboard/dashboards_test.go @@ -10,10 +10,9 @@ import ( "testing" "cuelang.org/go/cue/errors" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/coremodel/dashboard" "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/grafana/pkg/kinds/dashboard" + "github.com/stretchr/testify/require" ) func TestDevenvDashboardValidity(t *testing.T) { @@ -22,7 +21,7 @@ func TestDevenvDashboardValidity(t *testing.T) { m, err := themaTestableDashboards(os.DirFS(path)) require.NoError(t, err) - cm, err := dashboard.New(cuectx.GrafanaThemaRuntime()) + dk, err := dashboard.NewKind(cuectx.GrafanaThemaRuntime()) require.NoError(t, err) for path, b := range m { @@ -31,7 +30,7 @@ func TestDevenvDashboardValidity(t *testing.T) { cv, err := cuectx.JSONtoCUE(path, b) require.NoError(t, err, "error while decoding dashboard JSON into a CUE value") - _, err = cm.CurrentSchema().Validate(cv) + _, err = dk.ConvergentLineage().TypedSchema().Validate(cv) if err != nil { // Testify trims errors to short length. We want the full text errstr := errors.Details(err, nil) diff --git a/pkg/kinds/playlist/playlist_kind_gen.go b/pkg/kinds/playlist/playlist_kind_gen.go new file mode 100644 index 00000000000..2ae79506035 --- /dev/null +++ b/pkg/kinds/playlist/playlist_kind_gen.go @@ -0,0 +1,104 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// CoreStructuredKindJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package playlist + +import ( + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" + "github.com/grafana/thema/vmux" +) + +// rootrel is the relative path from the grafana repository root to the +// directory containing the .cue files in which this kind is declared. Necessary +// for runtime errors related to the declaration and/or lineage to provide +// a real path to the correct .cue file. +const rootrel string = "kinds/structured/playlist" + +// TODO standard generated docs +type Kind struct { + lin thema.ConvergentLineage[*Playlist] + jendec vmux.Endec + valmux vmux.ValueMux[*Playlist] + decl kindsys.Decl[kindsys.CoreStructuredMeta] +} + +// type guard +var _ kindsys.Structured = &Kind{} + +// TODO standard generated docs +func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { + decl, err := kindsys.LoadCoreKind[kindsys.CoreStructuredMeta](rootrel, rt.Context(), nil) + if err != nil { + return nil, err + } + k := &Kind{ + decl: *decl, + } + + lin, err := decl.Some().BindKindLineage(rt, opts...) + if err != nil { + return nil, err + } + + // Get the thema.Schema that the meta says is in the current version (which + // codegen ensures is always the latest) + cursch := thema.SchemaP(lin, k.decl.Meta.CurrentVersion) + tsch, err := thema.BindType[*Playlist](cursch, &Playlist{}) + if err != nil { + // Should be unreachable, modulo bugs in the Thema->Go code generator + return nil, err + } + + k.jendec = vmux.NewJSONEndec("playlist.json") + k.lin = tsch.ConvergentLineage() + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jendec) + return k, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "playlist" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "playlist" +} + +// TODO standard generated docs +func (k *Kind) Lineage() thema.Lineage { + return k.lin +} + +// TODO standard generated docs +func (k *Kind) ConvergentLineage() thema.ConvergentLineage[*Playlist] { + return k.lin +} + +// JSONValueMux is a version multiplexer that maps a []byte containing JSON data +// at any schematized dashboard version to an instance of Playlist. +// +// Validation and translation errors emitted from this func will identify the +// input bytes as "dashboard.json". +// +// This is a thin wrapper around Thema's [vmux.ValueMux]. +func (k *Kind) JSONValueMux(b []byte) (*Playlist, thema.TranslationLacunas, error) { + return k.valmux(b) +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Meta.Maturity +} + +// TODO standard generated docs +func (k *Kind) Meta() kindsys.CoreStructuredMeta { + return k.decl.Meta +} diff --git a/pkg/kinds/playlist/playlist_types_gen.go b/pkg/kinds/playlist/playlist_types_gen.go new file mode 100644 index 00000000000..d45a61f3e2c --- /dev/null +++ b/pkg/kinds/playlist/playlist_types_gen.go @@ -0,0 +1,59 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// GoTypesJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package playlist + +// Defines values for PlaylistItemType. +const ( + PlaylistItemTypeDashboardById PlaylistItemType = "dashboard_by_id" + + PlaylistItemTypeDashboardByTag PlaylistItemType = "dashboard_by_tag" + + PlaylistItemTypeDashboardByUid PlaylistItemType = "dashboard_by_uid" +) + +// Playlist defines model for playlist. +type Playlist struct { + // Interval sets the time between switching views in a playlist. + // FIXME: Is this based on a standardized format or what options are available? Can datemath be used? + Interval string `json:"interval"` + + // The ordered list of items that the playlist will iterate over. + // FIXME! This should not be optional, but changing it makes the godegen awkward + Items *[]PlaylistItem `json:"items,omitempty"` + + // Name of the playlist. + Name string `json:"name"` + + // Unique playlist identifier. Generated on creation, either by the + // creator of the playlist of by the application. + Uid string `json:"uid"` +} + +// PlaylistItem defines model for playlist.Item. +type PlaylistItem struct { + // Title is an unused property -- it will be removed in the future + Title *string `json:"title,omitempty"` + + // Type of the item. + Type PlaylistItemType `json:"type"` + + // Value depends on type and describes the playlist item. + // + // - dashboard_by_id: The value is an internal numerical identifier set by Grafana. This + // is not portable as the numerical identifier is non-deterministic between different instances. + // Will be replaced by dashboard_by_uid in the future. (deprecated) + // - dashboard_by_tag: The value is a tag which is set on any number of dashboards. All + // dashboards behind the tag will be added to the playlist. + // - dashboard_by_uid: The value is the dashboard UID + Value string `json:"value"` +} + +// Type of the item. +type PlaylistItemType string diff --git a/pkg/kinds/svg/svg_kind_gen.go b/pkg/kinds/svg/svg_kind_gen.go new file mode 100644 index 00000000000..9722d91998b --- /dev/null +++ b/pkg/kinds/svg/svg_kind_gen.go @@ -0,0 +1,54 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// RawKindJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package svg + +import ( + "github.com/grafana/grafana/pkg/kindsys" +) + +// TODO standard generated docs +type Kind struct { + decl kindsys.Decl[kindsys.RawMeta] +} + +// type guard +var _ kindsys.Raw = &Kind{} + +// TODO standard generated docs +func NewKind() (*Kind, error) { + decl, err := kindsys.LoadCoreKind[kindsys.RawMeta]("kinds/raw/svg", nil, nil) + if err != nil { + return nil, err + } + + return &Kind{ + decl: *decl, + }, nil +} + +// TODO standard generated docs +func (k *Kind) Name() string { + return "SVG" +} + +// TODO standard generated docs +func (k *Kind) MachineName() string { + return "svg" +} + +// TODO standard generated docs +func (k *Kind) Maturity() kindsys.Maturity { + return k.decl.Meta.Maturity +} + +// TODO standard generated docs +func (k *Kind) Meta() kindsys.RawMeta { + return k.decl.Meta +} diff --git a/pkg/kindsys/EXTENDING.md b/pkg/kindsys/EXTENDING.md new file mode 100644 index 00000000000..93f36639f45 --- /dev/null +++ b/pkg/kindsys/EXTENDING.md @@ -0,0 +1,60 @@ +# Kind System + +This package contains Grafana's kind system, which defines the rules that govern all Grafana kind declarations, including both core and plugin kinds. It contains many contracts on which public promises of backwards compatibility are made. All changes must be considered with care. + +While this package is maintained by @grafana/grafana-as-code, contributions from others are a main goal! Any time you have the thought, "I wish this part of Grafana's codebase was consistent," rather than writing docs (that people will inevitably miss), it's worth seeing if you can express that consistency as a kindsys extension instead. + +This document is the guide to extending kindsys. But first, we have to identify kindsys's key components. + +## Elements of kindsys + +* **CUE framework** - the collection of .cue files in this directory, `pkg/kindsys`. These are schemas that define how Kinds are declared. +* **Go framework** - the Go package in this directory containing utilities for loading individual kind declarations, validating them against the CUE framework, and representing them consistently in Go. +* **Code generators** - `pkg/codegen` contains the codegen framework. Individual generators (which take one or many `pkg/kindsys.Decl`, and produce a single file) each have a `pkg/codegen/generator_*.go` file. +* **Registries** - generated lists of all or a well-defined subset of kinds that can be used in code. `pkg/registries/corekind` is a registry of all core `pkg/kindsys.Interface` implementations; `packages/grafana-schema/src/index.gen.ts` is a registry of all the TypeScript types generated from the current versions of each kind's schema. +* **Kind declarations** - the declarations of individual kinds. By kind category: + * **Core Structured** - each child directory of `kinds/structured`. + * **Raw** - each child directory of `kinds/raw`. + * **Composable** - In Grafana core, `public/app/plugins/*/*/models.cue` files. + * **Custom** - No examples in Grafana core. See [operator-app-sdk](https://github.com/grafana/operator-app-sdk) (TODO that repo is private; make it public, or point to public examples). + +The above are treated as similarly to stateless libraries - a layer beneath the main Grafana frontend and backend without dependencies on it (no storage, no API, no wire, etc.). This lack of dependencies, and their Apache v2 licensing, allow their use as libraries for external tools. + +## Extending kindsys + +Extending the kind system generally involves: + +* Introducing one or more new fields into the CUE framework +* Updating the Go framework to accommodate the new fields +* Updating the kind authoring and maturity planning docs to reflect the new extension +* (possibly) Writing one or more new code generators +* (possibly) Writing/refactoring some frontend code that depends on new codegen output +* (possibly) Writing/refactoring some backend code that depends on codegen output and/or the Go kind framework +* (possibly) Tweaking all existing kinds as-needed to accommodate the new extension + +_TODO detailed guide to the above steps_ + +The above steps certainly aren't trivial. But they all come only after figuring out a way to solve the problem you want to solve in terms of the kind system and code generation in the first place. + +_TODO brief guide on how to think in codegen_ + +## Extensions not involving kind metadata + +While the main path for extending kindsys is through adding metadata, there are some other ways of extending kindsys. + +### CUE attributes + +[CUE attributes](https://cuelang.org/docs/references/spec/#attributes) provide additional information to kind tooling. They are suitable when it is necessary for a schema author to express additional information about a particular field or definition within a schema, without actually modifying the meaning of the schema. Two such known patterns are: + +* Controlling nuanced behavior of code generators for some field or type. Example: [@cuetsy](https://github.com/grafana/cuetsy#usage) attributes, which govern TS output +* Expressing some kind of structured TODO or WIP information on a field or type that can be easily analyzed and fed into other systems. Example: a kind marked at least `stable` maturity may not have any `@grafanamaturity` attributes + +In both of these cases, attributes are a tool _for the individual kind author_ to convey something to downstream consumers of kind declarations. It is essential. While attributes allow consistency in _how_ a particular task is accomplished, they leave _when_ to apply the rule up to the judgment of the kind author. + +Attributes occupy an awkward middle ground. They are more challenging to implement than standard kind framework properties, and less consistent than general codegen transformers while still imposing a cognitive burden on kind authors. They should be the last tool you reach for - but may be the only tool available when field-level schema metadata is required. + +TODO create a general pattern for self-contained attribute parser/validators to follow + +### Codegen transformers + +TODO actually write this - use `Uid`->`UID` as example diff --git a/pkg/kindsys/errors.go b/pkg/kindsys/errors.go new file mode 100644 index 00000000000..a02fb887ed9 --- /dev/null +++ b/pkg/kindsys/errors.go @@ -0,0 +1,35 @@ +package kindsys + +import "errors" + +// TODO consider rewriting with https://github.com/cockroachdb/errors + +var ( + // ErrValueNotExist indicates that a necessary CUE value did not exist. + ErrValueNotExist = errors.New("cue value does not exist") + + // ErrValueNotAKind indicates that a provided CUE value is not any variety of + // Interface. This is almost always an end-user error - they oops'd and provided the + // wrong path, file, etc. + ErrValueNotAKind = errors.New("not a kind") +) + +func ewrap(actual, is error) error { + return &errPassthrough{ + actual: actual, + is: is, + } +} + +type errPassthrough struct { + actual error + is error +} + +func (e *errPassthrough) Is(err error) bool { + return errors.Is(err, e.actual) || errors.Is(err, e.is) +} + +func (e *errPassthrough) Error() string { + return e.actual.Error() +} diff --git a/pkg/kindsys/kind.go b/pkg/kindsys/kind.go new file mode 100644 index 00000000000..ea02870ce26 --- /dev/null +++ b/pkg/kindsys/kind.go @@ -0,0 +1,82 @@ +package kindsys + +import ( + "fmt" + + "github.com/grafana/thema" +) + +// TODO docs +type Maturity string + +const ( + MaturityMerged Maturity = "merged" + MaturityExperimental Maturity = "experimental" + MaturityStable Maturity = "stable" + MaturityMature Maturity = "mature" +) + +func maturityIdx(m Maturity) int { + // icky to do this globally, this is effectively setting a default + if string(m) == "" { + m = MaturityMerged + } + + for i, ms := range maturityOrder { + if m == ms { + return i + } + } + panic(fmt.Sprintf("unknown maturity milestone %s", m)) +} + +var maturityOrder = []Maturity{ + MaturityMerged, + MaturityExperimental, + MaturityStable, + MaturityMature, +} + +func (m Maturity) Less(om Maturity) bool { + return maturityIdx(m) < maturityIdx(om) +} + +// TODO docs +type Interface interface { + // TODO docs + Name() string + + // TODO docs + MachineName() string + + // TODO docs + Maturity() Maturity // TODO unclear if we want maturity for raw kinds +} + +// TODO docs +type Raw interface { + Interface + + // TODO docs + Meta() RawMeta +} + +type Structured interface { + Interface + + // TODO docs + Lineage() thema.Lineage + + // TODO docs + Meta() CoreStructuredMeta // TODO figure out how to reconcile this interface with CustomStructuredMeta +} + +// type Composable interface { +// Interface +// +// // TODO docs +// Lineage() thema.Lineage +// +// // TODO docs +// Meta() CoreStructuredMeta // TODO figure out how to reconcile this interface with CustomStructuredMeta +// } diff --git a/pkg/kindsys/kindcats.cue b/pkg/kindsys/kindcats.cue new file mode 100644 index 00000000000..4321b45b78b --- /dev/null +++ b/pkg/kindsys/kindcats.cue @@ -0,0 +1,166 @@ +package kindsys + +import ( + "strings" + + "github.com/grafana/thema" +) + +// A Kind specifies a type of Grafana resource. +// +// An instance of a Kind is called an entity. An entity is a sequence of bytes - +// for example, a JSON file or HTTP request body - that conforms to the +// constraints defined in a Kind, and enforced by Grafana's entity system. +// +// Once Grafana has determined a given byte sequence to be an +// instance of a known Kind, kind-specific behaviors can be applied, +// requests can be routed, events can be triggered, etc. +// +// Classes and objects in most programming languages are analogous: +// - #Kind is like a `class` keyword +// - Each declaration of #Kind is like a class declaration +// - Byte sequences are like arguments to the class constructor +// - Entities are like objects - what's returned from the constructor +// +// There are four categories of kinds: Raw, Composable, CoreStructured, +// and CustomStructured. +#Kind: #Raw | #Composable | #CoreStructured | #CustomStructured + +// properties shared between all kind categories. +_sharedKind: { + // name is the canonical name of a Kind, as expressed in PascalCase. + // + // To ensure names are generally portable and amenable for consumption + // in various mechanical tasks, name largely follows the relatively + // strict DNS label naming standard as defined in RFC 1123: + // - Contain at most 63 characters + // - Contain only lowercase alphanumeric characters or '-' + // - Start with an uppercase alphabetic character + // - End with an alphanumeric character + name: =~"^([A-Z][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$" + + // machineName is the case-normalized (lowercase) version of [name]. This + // version of the name is preferred for use in most mechanical contexts, + // as case normalization ensures that case-insensitive and case-sensitive + // checks will never disagree on uniqueness. + // + // In addition to lowercase normalization, dashes are transformed to underscores. + machineName: strings.ToLower(strings.Replace(name, "-", "_", -1)) + + // pluralName is the pluralized form of name. Defaults to name + "s". + pluralName: =~"^([A-Z][a-zA-Z0-9-]{0,61}[a-zA-Z])$" | *(name + "s") + + // pluralMachineName is the pluralized form of [machineName]. The same case + // normalization and dash transformation is applied to [pluralName] as [machineName] + // applies to [name]. + pluralMachineName: strings.ToLower(strings.Replace(pluralName, "-", "_", -1)) + + // lineageIsGroup indicates whether the lineage in this kind is "grouped". In a + // grouped lineage, each top-level field in the schema specifies a discrete + // object that is expected to exist in the wild + // + // This field is set at the framework level, and cannot be in the declaration of + // any individual kind. + // + // This is likely to eventually become a first-class property in Thema: + // https://github.com/grafana/thema/issues/62 + lineageIsGroup: bool + + maturity: #Maturity + + // The kind system itself is not mature enough yet for any single + // kind to advance beyond "experimental" + // TODO allow more maturity stages once system is ready https://github.com/orgs/grafana/projects/133/views/8 + maturity: *"merged" | "experimental" + + // form indicates whether the kind has a schema ("structured") or not ("raw") + form: "structured" | "raw" +} + +// Maturity indicates the how far a given kind declaration is in its initial +// journey. Mature kinds still evolve, but with guarantees about compatibility. +#Maturity: "merged" | "experimental" | "stable" | "mature" + +// Structured encompasses all three of the structured kind categories, in which +// a schema specifies validity rules for the byte sequence. These represent all +// the conventional types and functional resources in Grafana, such as +// dashboards and datasources. +// +// Structured kinds may be defined either by Grafana itself (#CoreStructured), +// or by plugins (#CustomStructured). Plugin-defined kinds have a slightly +// reduced set of capabilities, due to the constraints imposed by them being run +// in separate processes, and the risks arising from executing code from +// potentially untrusted third parties. +#Structured: S={ + _sharedKind + form: "structured" + + // lineage is the Thema lineage containing all the schemas that have existed for this kind. + // It is required that lineage.name is the same as the [machineName]. + lineage: thema.#Lineage & { name: S.machineName } + + currentVersion: thema.#SyntacticVersion & (thema.#LatestVersion & {lin: lineage}).out +} + +// Raw is a category of Kind that specifies handling for a raw file, +// like an image, or an svg or parquet file. Grafana mostly acts as asset storage for raw +// kinds: the byte sequence is a black box to Grafana, and type is determined +// through metadata such as file extension. +#Raw: { + _sharedKind + form: "raw" + + // TODO docs + extensions?: [...string] + + lineageIsGroup: false + + // known TODOs + // - sanitize function + // - get summary +} + +// TODO +#CustomStructured: { + #Structured + + lineageIsGroup: false + ... +} + +// TODO +#CoreStructured: { + #Structured + + lineageIsGroup: false +} + +// Composable is a category of structured kind that provides schema elements for +// composition into CoreStructured and CustomStructured kinds. Grafana plugins +// provide composable kinds; for example, a datasource plugin provides one to +// describe the structure of its queries, which is then composed into dashboards +// and alerting rules. +// +// Each Composable is an implementation of exactly one Slot, a shared meta-schema +// defined by Grafana itself that constrains the shape of schemas declared in +// that ComposableKind. +#Composable: S={ + _sharedKind + form: "structured" + + // TODO docs + // TODO unify this with the existing slots decls in pkg/framework/coremodel + slot: "Panel" | "Query" | "DSConfig" + + // TODO unify this with the existing slots decls in pkg/framework/coremodel + lineageIsGroup: bool & [ + if slot == "Panel" { true }, + if slot == "DSConfig" { true }, + if slot == "Query" { false }, + ][0] + + // lineage is the Thema lineage containing all the schemas that have existed for this kind. + // It is required that lineage.name is the same as the [machineName]. + lineage: thema.#Lineage & { name: S.machineName } +} + diff --git a/pkg/kindsys/kindmetas.go b/pkg/kindsys/kindmetas.go new file mode 100644 index 00000000000..946c07fbe76 --- /dev/null +++ b/pkg/kindsys/kindmetas.go @@ -0,0 +1,74 @@ +package kindsys + +import "github.com/grafana/thema" + +// CommonMeta contains the kind metadata common to all categories of kinds. +type CommonMeta struct { + Name string `json:"name"` + PluralName string `json:"pluralName"` + MachineName string `json:"machineName"` + PluralMachineName string `json:"pluralMachineName"` + LineageIsGroup bool `json:"lineageIsGroup"` + Maturity Maturity `json:"maturity"` +} + +// TODO generate from type.cue +type RawMeta struct { + CommonMeta + Extensions []string `json:"extensions"` +} + +func (m RawMeta) _private() {} +func (m RawMeta) Common() CommonMeta { + return m.CommonMeta +} + +// TODO +type CoreStructuredMeta struct { + CommonMeta + CurrentVersion thema.SyntacticVersion `json:"currentVersion"` +} + +func (m CoreStructuredMeta) _private() {} +func (m CoreStructuredMeta) Common() CommonMeta { + return m.CommonMeta +} + +// TODO +type CustomStructuredMeta struct { + CommonMeta + CurrentVersion thema.SyntacticVersion `json:"currentVersion"` +} + +func (m CustomStructuredMeta) _private() {} +func (m CustomStructuredMeta) Common() CommonMeta { + return m.CommonMeta +} + +// TODO +type ComposableMeta struct { + CommonMeta + CurrentVersion thema.SyntacticVersion `json:"currentVersion"` +} + +func (m ComposableMeta) _private() {} +func (m ComposableMeta) Common() CommonMeta { + return m.CommonMeta +} + +// SomeKindMeta is an interface type to abstract over the different kind +// metadata struct types: [RawMeta], [CoreStructuredMeta], +// [CustomStructuredMeta]. +// +// It is the traditional interface counterpart to the generic type constraint +// KindMetas. +type SomeKindMeta interface { + _private() + Common() CommonMeta +} + +// KindMetas is a type parameter that comprises the base possible set of +// kind metadata configurations. +type KindMetas interface { + RawMeta | CoreStructuredMeta | CustomStructuredMeta | ComposableMeta +} diff --git a/pkg/kindsys/load.go b/pkg/kindsys/load.go new file mode 100644 index 00000000000..d8d4cda1c67 --- /dev/null +++ b/pkg/kindsys/load.go @@ -0,0 +1,242 @@ +package kindsys + +import ( + "fmt" + "io/fs" + "path/filepath" + "sync" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/errors" + "github.com/grafana/grafana" + "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/thema" + tload "github.com/grafana/thema/load" +) + +// CoreStructuredDeclParentPath is the path, relative to the repository root, where +// each child directory is expected to contain .cue files declaring one +// CoreStructured kind. +var CoreStructuredDeclParentPath = filepath.Join("kinds", "structured") + +// RawDeclParentPath is the path, relative to the repository root, where each child +// directory is expected to contain .cue files declaring one Raw kind. +var RawDeclParentPath = filepath.Join("kinds", "raw") + +// GoCoreKindParentPath is the path, relative to the repository root, to the directory +// containing one directory per kind, full of generated Go kind output: types and bindings. +var GoCoreKindParentPath = filepath.Join("pkg", "kinds") + +// TSCoreKindParentPath is the path, relative to the repository root, to the directory that +// contains one directory per kind, full of generated TS kind output: types and default consts. +var TSCoreKindParentPath = filepath.Join("packages", "grafana-schema", "src", "raw") + +var defaultFramework cue.Value +var fwOnce sync.Once + +func init() { + loadpFrameworkOnce() +} + +func loadpFrameworkOnce() { + fwOnce.Do(func() { + var err error + defaultFramework, err = doLoadFrameworkCUE(cuectx.GrafanaCUEContext()) + if err != nil { + panic(err) + } + }) +} + +var prefix = filepath.Join("/pkg", "kindsys") + +func doLoadFrameworkCUE(ctx *cue.Context) (cue.Value, error) { + var v cue.Value + var err error + + absolutePath := prefix + if !filepath.IsAbs(absolutePath) { + absolutePath, err = filepath.Abs(absolutePath) + if err != nil { + return v, err + } + } + + bi, err := tload.InstancesWithThema(grafana.CueSchemaFS, absolutePath) + if err != nil { + return v, err + } + v = ctx.BuildInstance(bi) + + if err = v.Validate(cue.Concrete(false), cue.All()); err != nil { + return cue.Value{}, fmt.Errorf("coremodel framework loaded cue.Value has err: %w", err) + } + + return v, nil +} + +// CUEFramework returns a cue.Value representing all the kind framework +// raw CUE files. +// +// For low-level use in constructing other types and APIs, while still letting +// us declare all the frameworky CUE bits in a single package. Other Go types +// make the constructs in this value easy to use. +// +// All calling code within grafana/grafana is expected to use Grafana's +// singleton [cue.Context], returned from [cuectx.GrafanaCUEContext]. If nil +// is passed, the singleton will be used. +func CUEFramework(ctx *cue.Context) cue.Value { + if ctx == nil || ctx == cuectx.GrafanaCUEContext() { + // Ensure framework is loaded, even if this func is called + // from an init() somewhere. + loadpFrameworkOnce() + return defaultFramework + } + // Error guaranteed to be nil here because erroring would have caused init() to panic + v, _ := doLoadFrameworkCUE(ctx) // nolint:errcheck + return v +} + +// ToKindMeta takes a cue.Value expected to represent a kind of the category +// specified by the type parameter and populates the Go type from the cue.Value. +func ToKindMeta[T KindMetas](v cue.Value) (T, error) { + meta := new(T) + if !v.Exists() { + return *meta, ErrValueNotExist + } + + fw := CUEFramework(v.Context()) + var kdef cue.Value + + anymeta := any(*meta).(SomeKindMeta) + switch anymeta.(type) { + case RawMeta: + kdef = fw.LookupPath(cue.MakePath(cue.Def("Raw"))) + case CoreStructuredMeta: + kdef = fw.LookupPath(cue.MakePath(cue.Def("CoreStructured"))) + case CustomStructuredMeta: + kdef = fw.LookupPath(cue.MakePath(cue.Def("CustomStructured"))) + case ComposableMeta: + kdef = fw.LookupPath(cue.MakePath(cue.Def("Composable"))) + default: + // unreachable so long as all the possibilities in KindMetas have switch branches + panic("unreachable") + } + + item := v.Unify(kdef) + if err := item.Validate(cue.Concrete(false), cue.All()); err != nil { + return *meta, ewrap(item.Err(), ErrValueNotAKind) + } + if err := item.Decode(meta); err != nil { + // Should only be reachable if CUE and Go framework types have diverged + panic(errors.Details(err, nil)) + } + + return *meta, nil +} + +// SomeDecl represents a single kind declaration, having been loaded +// and validated by a func such as [LoadCoreKind]. +// +// The underlying type of the Meta field indicates the category of +// kind. +type SomeDecl struct { + // V is the cue.Value containing the entire Kind declaration. + V cue.Value + // Meta contains the kind's metadata settings. + Meta SomeKindMeta +} + +// BindKindLineage binds the lineage for the kind declaration. nil, nil is returned +// for raw kinds. +// +// For kinds with a corresponding Go type, it is left to the caller to associate +// that Go type with the lineage returned from this function by a call to [thema.BindType]. +func (decl *SomeDecl) BindKindLineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { + if rt == nil { + rt = cuectx.GrafanaThemaRuntime() + } + switch decl.Meta.(type) { + case RawMeta: + return nil, nil + case CoreStructuredMeta, CustomStructuredMeta, ComposableMeta: + return thema.BindLineage(decl.V.LookupPath(cue.MakePath(cue.Str("lineage"))), rt, opts...) + default: + panic("unreachable") + } +} + +// IsRaw indicates whether the represented kind is a raw kind. +func (decl *SomeDecl) IsRaw() bool { + _, is := decl.Meta.(RawMeta) + return is +} + +// IsCoreStructured indicates whether the represented kind is a core structured kind. +func (decl *SomeDecl) IsCoreStructured() bool { + _, is := decl.Meta.(CoreStructuredMeta) + return is +} + +// IsCustomStructured indicates whether the represented kind is a custom structured kind. +func (decl *SomeDecl) IsCustomStructured() bool { + _, is := decl.Meta.(CustomStructuredMeta) + return is +} + +// IsComposable indicates whether the represented kind is a composable kind. +func (decl *SomeDecl) IsComposable() bool { + _, is := decl.Meta.(ComposableMeta) + return is +} + +// Decl represents a single kind declaration, having been loaded +// and validated by a func such as [LoadCoreKind]. +// +// Its type parameter indicates the category of kind. +type Decl[T KindMetas] struct { + // V is the cue.Value containing the entire Kind declaration. + V cue.Value + // Meta contains the kind's metadata settings. + Meta T +} + +// Some converts the typed Decl to the equivalent typeless SomeDecl. +func (decl *Decl[T]) Some() *SomeDecl { + return &SomeDecl{ + V: decl.V, + Meta: any(decl.Meta).(SomeKindMeta), + } +} + +// LoadCoreKind loads and validates a core kind declaration of the kind category +// indicated by the type parameter. On success, it returns a [Decl] which +// contains the entire contents of the kind declaration. +// +// declpath is the path to the directory containing the core kind declaration, +// relative to the grafana/grafana root. For example, dashboards are in +// "kinds/structured/dashboard". +// +// The .cue file bytes containing the core kind declaration will be retrieved +// from the central embedded FS, [grafana.CueSchemaFS]. If desired (e.g. for +// testing), an optional fs.FS may be provided via the overlay parameter, which +// will be merged over [grafana.CueSchemaFS]. But in typical circumstances, +// overlay can and should be nil. +// +// This is a low-level function, primarily intended for use in code generation. +// For representations of core kinds that are useful in Go programs at runtime, +// see ["github.com/grafana/grafana/pkg/registry/corekind"]. +func LoadCoreKind[T RawMeta | CoreStructuredMeta](declpath string, ctx *cue.Context, overlay fs.FS) (*Decl[T], error) { + vk, err := cuectx.BuildGrafanaInstance(declpath, "kind", ctx, overlay) + if err != nil { + return nil, err + } + decl := &Decl[T]{ + V: vk, + } + decl.Meta, err = ToKindMeta[T](vk) + if err != nil { + return nil, err + } + return decl, nil +} diff --git a/pkg/registry/corekind/base.go b/pkg/registry/corekind/base.go new file mode 100644 index 00000000000..c2540ea820f --- /dev/null +++ b/pkg/registry/corekind/base.go @@ -0,0 +1,76 @@ +package corekind + +import ( + "sync" + + "github.com/google/wire" + "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" +) + +// KindSet contains all of the wire-style providers related to kinds. +var KindSet = wire.NewSet( + NewBase, +) + +var ( + baseOnce sync.Once + defaultBase *Base +) + +// NewBase provides a registry of all core raw and structured kinds, without any +// composition of slot kinds. +// +// All calling code within grafana/grafana is expected to use Grafana's +// singleton [thema.Runtime], returned from [cuectx.GrafanaThemaRuntime]. If nil +// is passed, the singleton will be used. +func NewBase(rt *thema.Runtime) *Base { + allrt := cuectx.GrafanaThemaRuntime() + if rt == nil || rt == allrt { + baseOnce.Do(func() { + defaultBase = doNewBase(allrt) + }) + return defaultBase + } + + return doNewBase(rt) +} + +// All returns a slice of the [kindsys.Interface] instances corresponding to all +// core raw and structured kinds. +// +// The returned slice is sorted lexicographically by kind machine name. +func (b *Base) All() []kindsys.Interface { + ret := make([]kindsys.Interface, len(b.all)) + copy(ret, b.all) + return ret +} + +// AllRaw returns a slice of the [kindsys.Raw] instances for all raw kinds. +// +// The returned slice is sorted lexicographically by kind machine name. +func (b *Base) AllRaw() []kindsys.Raw { + ret := make([]kindsys.Raw, 0, b.numRaw) + for _, k := range b.all { + if rk, is := k.(kindsys.Raw); is { + ret = append(ret, rk) + } + } + + return ret +} + +// AllStructured returns a slice of the [kindsys.Structured] instances for +// all core structured kinds. +// +// The returned slice is sorted lexicographically by kind machine name. +func (b *Base) AllStructured() []kindsys.Structured { + ret := make([]kindsys.Structured, 0, b.numStructured) + for _, k := range b.all { + if rk, is := k.(kindsys.Structured); is { + ret = append(ret, rk) + } + } + return ret +} diff --git a/pkg/registry/corekind/base_gen.go b/pkg/registry/corekind/base_gen.go new file mode 100644 index 00000000000..b6064eaf1e1 --- /dev/null +++ b/pkg/registry/corekind/base_gen.go @@ -0,0 +1,88 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// kinds/gen.go +// Using jennies: +// BaseCoreRegistryJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +package corekind + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/kinds/dashboard" + "github.com/grafana/grafana/pkg/kinds/playlist" + "github.com/grafana/grafana/pkg/kinds/svg" + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/thema" +) + +// Base is a registry of kindsys.Interface. It provides two modes for accessing +// kinds: individually via literal named methods, or as a slice returned from +// an All*() method. +// +// Prefer the individual named methods for use cases where the particular kind(s) that +// are needed are known to the caller. For example, a dashboard linter can know that it +// specifically wants the dashboard kind. +// +// Prefer All*() methods when performing operations generically across all kinds. +// For example, a validation HTTP middleware for any kind-schematized object type. +type Base struct { + all []kindsys.Interface + numRaw, numStructured int + dashboard *dashboard.Kind + playlist *playlist.Kind + svg *svg.Kind +} + +// type guards +var ( + _ kindsys.Structured = &dashboard.Kind{} + _ kindsys.Structured = &playlist.Kind{} + _ kindsys.Raw = &svg.Kind{} +) + +// Dashboard returns the [kindsys.Interface] implementation for the dashboard kind. +func (b *Base) Dashboard() *dashboard.Kind { + return b.dashboard +} + +// Playlist returns the [kindsys.Interface] implementation for the playlist kind. +func (b *Base) Playlist() *playlist.Kind { + return b.playlist +} + +// SVG returns the [kindsys.Interface] implementation for the svg kind. +func (b *Base) SVG() *svg.Kind { + return b.svg +} + +func doNewBase(rt *thema.Runtime) *Base { + var err error + reg := &Base{ + numRaw: 1, + numStructured: 2, + } + + reg.dashboard, err = dashboard.NewKind(rt) + if err != nil { + panic(fmt.Sprintf("error while initializing the dashboard Kind: %s", err)) + } + reg.all = append(reg.all, reg.dashboard) + + reg.playlist, err = playlist.NewKind(rt) + if err != nil { + panic(fmt.Sprintf("error while initializing the playlist Kind: %s", err)) + } + reg.all = append(reg.all, reg.playlist) + + reg.svg, err = svg.NewKind() + if err != nil { + panic(fmt.Sprintf("error while initializing the svg Kind: %s", err)) + } + reg.all = append(reg.all, reg.svg) + + return reg +} diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 1123c997893..372d590faf1 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/expr" - cmreg "github.com/grafana/grafana/pkg/framework/coremodel/registry" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/httpclient/httpclientprovider" @@ -41,6 +40,7 @@ import ( managerStore "github.com/grafana/grafana/pkg/plugins/manager/store" "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/plugins/repo" + "github.com/grafana/grafana/pkg/registry/corekind" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" @@ -334,7 +334,7 @@ var wireBasicSet = wire.NewSet( avatar.ProvideAvatarCacheServer, authproxy.ProvideAuthProxy, statscollector.ProvideService, - cmreg.CoremodelSet, + corekind.KindSet, cuectx.GrafanaCUEContext, cuectx.GrafanaThemaRuntime, csrf.ProvideCSRFFilter, diff --git a/pkg/services/playlist/model.go b/pkg/services/playlist/model.go index 3c0f4b6ad64..20eb8087d00 100644 --- a/pkg/services/playlist/model.go +++ b/pkg/services/playlist/model.go @@ -3,7 +3,7 @@ package playlist import ( "errors" - "github.com/grafana/grafana/pkg/coremodel/playlist" + "github.com/grafana/grafana/pkg/kinds/playlist" ) // Typed errors @@ -22,7 +22,7 @@ type Playlist struct { OrgId int64 `json:"-" db:"org_id"` } -type PlaylistDTO = playlist.Model +type PlaylistDTO = playlist.Playlist type PlaylistItemDTO = playlist.PlaylistItem type PlaylistItemType = playlist.PlaylistItemType diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index 4afa3513856..16d20ab6144 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -5,7 +5,7 @@ import ( "strconv" "time" - "github.com/grafana/grafana/pkg/coremodel/dashboard" + "github.com/grafana/grafana/pkg/kinds/dashboard" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 884b82d8d59..b64f1cb9299 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -8,9 +8,9 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/components/simplejson" - dashboard2 "github.com/grafana/grafana/pkg/coremodel/dashboard" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + dashboard2 "github.com/grafana/grafana/pkg/kinds/dashboard" grafanamodels "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" @@ -1024,11 +1024,11 @@ func TestBuildAnonymousUser(t *testing.T) { sqlStore := db.InitTestDB(t) dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) - //publicdashboardStore := database.ProvideStore(sqlStore) - //service := &PublicDashboardServiceImpl{ + // publicdashboardStore := database.ProvideStore(sqlStore) + // service := &PublicDashboardServiceImpl{ // log: log.New("test.logger"), // store: publicdashboardStore, - //} + // } 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) diff --git a/pkg/services/store/kind/playlist/summary.go b/pkg/services/store/kind/playlist/summary.go index 2221f7cbe94..d5ca2b896ab 100644 --- a/pkg/services/store/kind/playlist/summary.go +++ b/pkg/services/store/kind/playlist/summary.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" - "github.com/grafana/grafana/pkg/coremodel/playlist" + "github.com/grafana/grafana/pkg/kinds/playlist" "github.com/grafana/grafana/pkg/models" ) @@ -22,7 +22,7 @@ func GetObjectSummaryBuilder() models.ObjectSummaryBuilder { } func summaryBuilder(ctx context.Context, uid string, body []byte) (*models.ObjectSummary, []byte, error) { - obj := &playlist.Model{} + obj := &playlist.Playlist{} err := json.Unmarshal(body, obj) if err != nil { return nil, nil, err // unable to read object diff --git a/pkg/services/store/kind/playlist/summary_test.go b/pkg/services/store/kind/playlist/summary_test.go index 49b18cead85..f6d346ea0ae 100644 --- a/pkg/services/store/kind/playlist/summary_test.go +++ b/pkg/services/store/kind/playlist/summary_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/grafana/grafana/pkg/coremodel/playlist" + "github.com/grafana/grafana/pkg/kinds/playlist" "github.com/stretchr/testify/require" ) @@ -16,7 +16,7 @@ func TestPlaylistSummary(t *testing.T) { _, _, err := builder(context.Background(), "abc", []byte("{invalid json")) require.Error(t, err) - playlist := playlist.Model{ + playlist := playlist.Playlist{ Interval: "30s", Name: "test", Items: &[]playlist.PlaylistItem{ diff --git a/public/app/features/playlist/types.ts b/public/app/features/playlist/types.ts index 0856917c176..65b291c3515 100644 --- a/public/app/features/playlist/types.ts +++ b/public/app/features/playlist/types.ts @@ -1,4 +1,4 @@ -import { PlaylistItem as PlaylistItemFromSchema } from '@grafana/schema/src/raw/playlist/x/playlist.gen'; +import { PlaylistItem as PlaylistItemFromSchema } from '@grafana/schema'; import { DashboardQueryResult } from '../search/service'; diff --git a/public/app/plugins/gen.go b/public/app/plugins/gen.go index cbb28c1ee53..ab9946669d6 100644 --- a/public/app/plugins/gen.go +++ b/public/app/plugins/gen.go @@ -32,7 +32,6 @@ var skipPlugins = map[string]bool{ const sep = string(filepath.Separator) -// Generate TypeScript for all plugin models.cue func main() { if len(os.Args) > 1 { fmt.Fprintf(os.Stderr, "plugin thema code generator does not currently accept any arguments\n, got %q", os.Args) From fd6edbf8c5a52619f00a7e4e440e8bc93df18260 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 10 Nov 2022 13:04:42 -0800 Subject: [PATCH 191/926] Canvas: Improve disabled inline editing UX (#58610) --- public/app/features/canvas/runtime/scene.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index 29ee1c39499..4dd40c9824d 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -478,7 +478,7 @@ export class Scene { this.selecto.getSelectedTargets()[0].style.cursor = 'grabbing'; } - if (isTargetMoveableElement || isTargetAlreadySelected) { + if (isTargetMoveableElement || isTargetAlreadySelected || !this.isEditingEnabled) { // Prevent drawing selection box when selected target is a moveable element or already selected event.stop(); } From 5fac98bcfd3d1706c1229ce566b4c9ac112055b9 Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Thu, 10 Nov 2022 15:39:27 -0600 Subject: [PATCH 192/926] Docs: Add feature flag example to override configuration with environment variables (#58613) --- docs/sources/setup-grafana/configure-grafana/_index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index bdced3e305a..80c5336a2a1 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -71,6 +71,9 @@ client_secret = 0ldS3cretKey [plugin.grafana-image-renderer] rendering_ignore_https_errors = true + +[feature_toggles] +enable = newNavigation ``` You can override them on Linux machines with: @@ -80,6 +83,7 @@ export GF_DEFAULT_INSTANCE_NAME=my-instance export GF_SECURITY_ADMIN_USER=owner export GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey export GF_PLUGIN_GRAFANA_IMAGE_RENDERER_RENDERING_IGNORE_HTTPS_ERRORS=true +export GF_FEATURE_TOGGLES_ENABLE=newNavigation ``` ## Variable expansion From c090de9ed9e3e3865d36a27256a28c8f64179736 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Fri, 11 Nov 2022 04:19:29 -0500 Subject: [PATCH 193/926] Chore: Move dev-dashboards jsonnet into separate directory (#58619) --- Makefile | 2 +- devenv/dev-dashboards/Makefile | 12 -- devenv/dev-dashboards/dashboards.go | 3 - devenv/dev-dashboards/main.libsonnet | 1 - devenv/jsonnet/Makefile | 12 ++ .../gen.go => jsonnet/dev-dashboards.go} | 2 +- .../dev-dashboards.libsonnet} | 200 +++++++++--------- devenv/jsonnet/gen.go | 3 + .../jsonnetfile.json | 0 .../jsonnetfile.lock.json | 0 devenv/jsonnet/main.libsonnet | 1 + .../tmpl/gen.libsonnet.tmpl | 2 +- 12 files changed, 119 insertions(+), 119 deletions(-) delete mode 100644 devenv/dev-dashboards/Makefile delete mode 100644 devenv/dev-dashboards/main.libsonnet create mode 100644 devenv/jsonnet/Makefile rename devenv/{dev-dashboards/gen.go => jsonnet/dev-dashboards.go} (98%) rename devenv/{dev-dashboards/gen.libsonnet => jsonnet/dev-dashboards.libsonnet} (52%) create mode 100644 devenv/jsonnet/gen.go rename devenv/{dev-dashboards => jsonnet}/jsonnetfile.json (100%) rename devenv/{dev-dashboards => jsonnet}/jsonnetfile.lock.json (100%) create mode 100644 devenv/jsonnet/main.libsonnet rename devenv/{dev-dashboards => jsonnet}/tmpl/gen.libsonnet.tmpl (87%) diff --git a/Makefile b/Makefile index df5598c1bf9..52700ac46bb 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ gen-go: $(WIRE) gen-cue $(WIRE) gen -tags $(WIRE_TAGS) ./pkg/server ./pkg/cmd/grafana-cli/runner gen-jsonnet: - go generate ./devenv/dev-dashboards + go generate ./devenv/jsonnet build-go: $(MERGED_SPEC_TARGET) gen-go ## Build all Go binaries. @echo "build go files" diff --git a/devenv/dev-dashboards/Makefile b/devenv/dev-dashboards/Makefile deleted file mode 100644 index 2cd9e1a12ca..00000000000 --- a/devenv/dev-dashboards/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -include ../../.bingo/Variables.mk - -DASHBOARDS = $(shell find ./ -type f -name '*.json') -TEMPLATES = $(shell find ./ -type f -name '*.tmpl') - -vendor: jsonnetfile.json jsonnetfile.lock.json - $(JB) install - -gen.libsonnet: $(DASHBOARDS) $(TEMPLATES) vendor gen.go - go generate ./ - -main.libsonnet: gen.libsonnet \ No newline at end of file diff --git a/devenv/dev-dashboards/dashboards.go b/devenv/dev-dashboards/dashboards.go index 4405491c7ad..4531431bc7a 100644 --- a/devenv/dev-dashboards/dashboards.go +++ b/devenv/dev-dashboards/dashboards.go @@ -2,8 +2,5 @@ package dev_dashboards import "embed" -// generate gen.libsonnet -//go:generate go run gen.go - //go:embed *.json */*.json var DevDashboardFS embed.FS diff --git a/devenv/dev-dashboards/main.libsonnet b/devenv/dev-dashboards/main.libsonnet deleted file mode 100644 index b4c6287334d..00000000000 --- a/devenv/dev-dashboards/main.libsonnet +++ /dev/null @@ -1 +0,0 @@ -(import 'gen.libsonnet') \ No newline at end of file diff --git a/devenv/jsonnet/Makefile b/devenv/jsonnet/Makefile new file mode 100644 index 00000000000..12798a1e939 --- /dev/null +++ b/devenv/jsonnet/Makefile @@ -0,0 +1,12 @@ +include ../../.bingo/Variables.mk + +DASHBOARDS = $(shell find ../dev-dashboards -type f -name '*.json') +TEMPLATES = $(shell find ./ -type f -name '*.tmpl') + +vendor: jsonnetfile.json jsonnetfile.lock.json + $(JB) install + +dev-dashboards.libsonnet: $(DASHBOARDS) $(TEMPLATES) vendor dev-dashboards.go + go generate ./ + +main.libsonnet: dev-dashboards.libsonnet \ No newline at end of file diff --git a/devenv/dev-dashboards/gen.go b/devenv/jsonnet/dev-dashboards.go similarity index 98% rename from devenv/dev-dashboards/gen.go rename to devenv/jsonnet/dev-dashboards.go index b785bbd6886..b1d926a25ff 100644 --- a/devenv/dev-dashboards/gen.go +++ b/devenv/jsonnet/dev-dashboards.go @@ -18,7 +18,7 @@ import ( ) var ( - OUTPUT_PATH = "gen.libsonnet" + OUTPUT_PATH = "dev-dashboards.libsonnet" EXCLUDE = map[string]struct{}{ "jsonnetfile.json": {}, "jsonnetfile.lock.json": {}, diff --git a/devenv/dev-dashboards/gen.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet similarity index 52% rename from devenv/dev-dashboards/gen.libsonnet rename to devenv/jsonnet/dev-dashboards.libsonnet index cc86f0134d9..fa47dc594bf 100644 --- a/devenv/dev-dashboards/gen.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -9,700 +9,700 @@ local dashboard = grafana.dashboard; { folders: [grafana.folder.new('dev-dashboards', 'dev-dashboards')], dashboards: [ - dashboard.new('Repeating-Kitchen-Sink', import 'e2e-repeats/Repeating-Kitchen-Sink.json') + + dashboard.new('Repeating-Kitchen-Sink', import '../dev-dashboards/e2e-repeats/Repeating-Kitchen-Sink.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-panel-horizontally', import 'e2e-repeats/Repeating-a-panel-horizontally.json') + + dashboard.new('Repeating-a-panel-horizontally', import '../dev-dashboards/e2e-repeats/Repeating-a-panel-horizontally.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-panel-vertically', import 'e2e-repeats/Repeating-a-panel-vertically.json') + + dashboard.new('Repeating-a-panel-vertically', import '../dev-dashboards/e2e-repeats/Repeating-a-panel-vertically.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-row-with-a-non-repeating-pan', import 'e2e-repeats/Repeating-a-row-with-a-non-repeating-panel-and-horizontal-repeating-panel.json') + + dashboard.new('Repeating-a-row-with-a-non-repeating-pan', import '../dev-dashboards/e2e-repeats/Repeating-a-row-with-a-non-repeating-panel-and-horizontal-repeating-panel.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-row-with-a-non-repeating-pan', import 'e2e-repeats/Repeating-a-row-with-a-non-repeating-panel-and-vertical-repeating-panel.json') + + dashboard.new('Repeating-a-row-with-a-non-repeating-pan', import '../dev-dashboards/e2e-repeats/Repeating-a-row-with-a-non-repeating-panel-and-vertical-repeating-panel.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-row-with-a-non-repeating-pan', import 'e2e-repeats/Repeating-a-row-with-a-non-repeating-panel.json') + + dashboard.new('Repeating-a-row-with-a-non-repeating-pan', import '../dev-dashboards/e2e-repeats/Repeating-a-row-with-a-non-repeating-panel.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-row-with-a-repeating-horizon', import 'e2e-repeats/Repeating-a-row-with-a-repeating-horizontal-panel.json') + + dashboard.new('Repeating-a-row-with-a-repeating-horizon', import '../dev-dashboards/e2e-repeats/Repeating-a-row-with-a-repeating-horizontal-panel.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-a-row-with-a-repeating-vertica', import 'e2e-repeats/Repeating-a-row-with-a-repeating-vertical-panel.json') + + dashboard.new('Repeating-a-row-with-a-repeating-vertica', import '../dev-dashboards/e2e-repeats/Repeating-a-row-with-a-repeating-vertical-panel.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('Repeating-an-empty-row', import 'e2e-repeats/Repeating-an-empty-row.json') + + dashboard.new('Repeating-an-empty-row', import '../dev-dashboards/e2e-repeats/Repeating-an-empty-row.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('all-panels', import 'all-panels.json') + + dashboard.new('all-panels', import '../dev-dashboards/all-panels.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('auto_decimals', import 'panel-common/auto_decimals.json') + + dashboard.new('auto_decimals', import '../dev-dashboards/panel-common/auto_decimals.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('bar-gauge-demo2', import 'datasource-testdata/bar-gauge-demo2.json') + + dashboard.new('bar-gauge-demo2', import '../dev-dashboards/datasource-testdata/bar-gauge-demo2.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('bar_gauge_demo', import 'panel-bargauge/bar_gauge_demo.json') + + dashboard.new('bar_gauge_demo', import '../dev-dashboards/panel-bargauge/bar_gauge_demo.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('barchart-autosizing', import 'panel-barchart/barchart-autosizing.json') + + dashboard.new('barchart-autosizing', import '../dev-dashboards/panel-barchart/barchart-autosizing.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('barchart-thresholds-mappings', import 'panel-barchart/barchart-thresholds-mappings.json') + + dashboard.new('barchart-thresholds-mappings', import '../dev-dashboards/panel-barchart/barchart-thresholds-mappings.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('candlestick', import 'panel-candlestick/candlestick.json') + + dashboard.new('candlestick', import '../dev-dashboards/panel-candlestick/candlestick.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('canvas-examples', import 'panel-canvas/canvas-examples.json') + + dashboard.new('canvas-examples', import '../dev-dashboards/panel-canvas/canvas-examples.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('color_modes', import 'panel-common/color_modes.json') + + dashboard.new('color_modes', import '../dev-dashboards/panel-common/color_modes.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('config-from-query', import 'transforms/config-from-query.json') + + dashboard.new('config-from-query', import '../dev-dashboards/transforms/config-from-query.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('demo1', import 'datasource-testdata/demo1.json') + + dashboard.new('demo1', import '../dev-dashboards/datasource-testdata/demo1.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('elasticsearch_compare', import 'datasource-elasticsearch/elasticsearch_compare.json') + + dashboard.new('elasticsearch_compare', import '../dev-dashboards/datasource-elasticsearch/elasticsearch_compare.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('elasticsearch_v7', import 'datasource-elasticsearch/elasticsearch_v7.json') + + dashboard.new('elasticsearch_v7', import '../dev-dashboards/datasource-elasticsearch/elasticsearch_v7.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('elasticsearch_v7_filebeat', import 'datasource-elasticsearch/elasticsearch_v7_filebeat.json') + + dashboard.new('elasticsearch_v7_filebeat', import '../dev-dashboards/datasource-elasticsearch/elasticsearch_v7_filebeat.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('gauge-multi-series', import 'panel-gauge/gauge-multi-series.json') + + dashboard.new('gauge-multi-series', import '../dev-dashboards/panel-gauge/gauge-multi-series.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('gauge_tests', import 'panel-gauge/gauge_tests.json') + + dashboard.new('gauge_tests', import '../dev-dashboards/panel-gauge/gauge_tests.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('geomap-color-field', import 'panel-geomap/geomap-color-field.json') + + dashboard.new('geomap-color-field', import '../dev-dashboards/panel-geomap/geomap-color-field.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('geomap-photo-layer', import 'panel-geomap/geomap-photo-layer.json') + + dashboard.new('geomap-photo-layer', import '../dev-dashboards/panel-geomap/geomap-photo-layer.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('geomap-spatial-operations-transformer', import 'panel-geomap/geomap-spatial-operations-transformer.json') + + dashboard.new('geomap-spatial-operations-transformer', import '../dev-dashboards/panel-geomap/geomap-spatial-operations-transformer.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('geomap-v91', import 'panel-geomap/geomap-v91.json') + + dashboard.new('geomap-v91', import '../dev-dashboards/panel-geomap/geomap-v91.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('geomap_multi-layers', import 'panel-geomap/geomap_multi-layers.json') + + dashboard.new('geomap_multi-layers', import '../dev-dashboards/panel-geomap/geomap_multi-layers.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('global-variables-and-interpolation', import 'feature-templating/global-variables-and-interpolation.json') + + dashboard.new('global-variables-and-interpolation', import '../dev-dashboards/feature-templating/global-variables-and-interpolation.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('graph-gradient-area-fills', import 'panel-graph/graph-gradient-area-fills.json') + + dashboard.new('graph-gradient-area-fills', import '../dev-dashboards/panel-graph/graph-gradient-area-fills.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('graph-shared-tooltips', import 'panel-graph/graph-shared-tooltips.json') + + dashboard.new('graph-shared-tooltips', import '../dev-dashboards/panel-graph/graph-shared-tooltips.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('graph-time-regions', import 'panel-graph/graph-time-regions.json') + + dashboard.new('graph-time-regions', import '../dev-dashboards/panel-graph/graph-time-regions.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('graph_tests', import 'panel-graph/graph_tests.json') + + dashboard.new('graph_tests', import '../dev-dashboards/panel-graph/graph_tests.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('graph_y_axis', import 'panel-graph/graph_y_axis.json') + + dashboard.new('graph_y_axis', import '../dev-dashboards/panel-graph/graph_y_axis.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('heatmap-calculate-log', import 'panel-heatmap/heatmap-calculate-log.json') + + dashboard.new('heatmap-calculate-log', import '../dev-dashboards/panel-heatmap/heatmap-calculate-log.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('heatmap-legacy', import 'panel-heatmap/heatmap-legacy.json') + + dashboard.new('heatmap-legacy', import '../dev-dashboards/panel-heatmap/heatmap-legacy.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('histogram_tests', import 'panel-histogram/histogram_tests.json') + + dashboard.new('histogram_tests', import '../dev-dashboards/panel-histogram/histogram_tests.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('home', import 'home.json') + + dashboard.new('home', import '../dev-dashboards/home.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('influxdb-logs', import 'datasource-influxdb/influxdb-logs.json') + + dashboard.new('influxdb-logs', import '../dev-dashboards/datasource-influxdb/influxdb-logs.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('influxdb-templated', import 'datasource-influxdb/influxdb-templated.json') + + dashboard.new('influxdb-templated', import '../dev-dashboards/datasource-influxdb/influxdb-templated.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('join-by-field', import 'transforms/join-by-field.json') + + dashboard.new('join-by-field', import '../dev-dashboards/transforms/join-by-field.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('join-by-labels', import 'transforms/join-by-labels.json') + + dashboard.new('join-by-labels', import '../dev-dashboards/transforms/join-by-labels.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('lazy_loading', import 'panel-common/lazy_loading.json') + + dashboard.new('lazy_loading', import '../dev-dashboards/panel-common/lazy_loading.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('linked-viz', import 'panel-common/linked-viz.json') + + dashboard.new('linked-viz', import '../dev-dashboards/panel-common/linked-viz.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('loki_fakedata', import 'datasource-loki/loki_fakedata.json') + + dashboard.new('loki_fakedata', import '../dev-dashboards/datasource-loki/loki_fakedata.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('mssql_fakedata', import 'datasource-mssql/mssql_fakedata.json') + + dashboard.new('mssql_fakedata', import '../dev-dashboards/datasource-mssql/mssql_fakedata.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('mssql_unittest', import 'datasource-mssql/mssql_unittest.json') + + dashboard.new('mssql_unittest', import '../dev-dashboards/datasource-mssql/mssql_unittest.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('mysql_fakedata', import 'datasource-mysql/mysql_fakedata.json') + + dashboard.new('mysql_fakedata', import '../dev-dashboards/datasource-mysql/mysql_fakedata.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('mysql_unittest', import 'datasource-mysql/mysql_unittest.json') + + dashboard.new('mysql_unittest', import '../dev-dashboards/datasource-mysql/mysql_unittest.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('new_features_in_v62', import 'datasource-testdata/new_features_in_v62.json') + + dashboard.new('new_features_in_v62', import '../dev-dashboards/datasource-testdata/new_features_in_v62.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('new_features_in_v74', import 'datasource-testdata/new_features_in_v74.json') + + dashboard.new('new_features_in_v74', import '../dev-dashboards/datasource-testdata/new_features_in_v74.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('new_features_in_v8', import 'datasource-testdata/new_features_in_v8.json') + + dashboard.new('new_features_in_v8', import '../dev-dashboards/datasource-testdata/new_features_in_v8.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('opentsdb', import 'datasource-opentsdb/opentsdb.json') + + dashboard.new('opentsdb', import '../dev-dashboards/datasource-opentsdb/opentsdb.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('opentsdb_v23', import 'datasource-opentsdb/opentsdb_v23.json') + + dashboard.new('opentsdb_v23', import '../dev-dashboards/datasource-opentsdb/opentsdb_v23.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('panel-geomap', import 'panel-geomap/panel-geomap.json') + + dashboard.new('panel-geomap', import '../dev-dashboards/panel-geomap/panel-geomap.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('panel-stat-tests', import 'panel-stat/panel-stat-tests.json') + + dashboard.new('panel-stat-tests', import '../dev-dashboards/panel-stat/panel-stat-tests.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('panel_test_piechart', import 'panel-piechart/panel_test_piechart.json') + + dashboard.new('panel_test_piechart', import '../dev-dashboards/panel-piechart/panel_test_piechart.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('panel_tests_bar_gauge', import 'panel-bargauge/panel_tests_bar_gauge.json') + + dashboard.new('panel_tests_bar_gauge', import '../dev-dashboards/panel-bargauge/panel_tests_bar_gauge.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('panel_tests_bar_gauge2', import 'panel-bargauge/panel_tests_bar_gauge2.json') + + dashboard.new('panel_tests_bar_gauge2', import '../dev-dashboards/panel-bargauge/panel_tests_bar_gauge2.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('panels_without_title', import 'panel-common/panels_without_title.json') + + dashboard.new('panels_without_title', import '../dev-dashboards/panel-common/panels_without_title.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('polystat_test', import 'panel-polystat/polystat_test.json') + + dashboard.new('polystat_test', import '../dev-dashboards/panel-polystat/polystat_test.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('postgres_fakedata', import 'datasource-postgres/postgres_fakedata.json') + + dashboard.new('postgres_fakedata', import '../dev-dashboards/datasource-postgres/postgres_fakedata.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('postgres_unittest', import 'datasource-postgres/postgres_unittest.json') + + dashboard.new('postgres_unittest', import '../dev-dashboards/datasource-postgres/postgres_unittest.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('reuse', import 'transforms/reuse.json') + + dashboard.new('reuse', import '../dev-dashboards/transforms/reuse.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('rows-to-fields', import 'transforms/rows-to-fields.json') + + dashboard.new('rows-to-fields', import '../dev-dashboards/transforms/rows-to-fields.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('shared_queries', import 'panel-common/shared_queries.json') + + dashboard.new('shared_queries', import '../dev-dashboards/panel-common/shared_queries.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('slow_queries_and_annotations', import 'scenarios/slow_queries_and_annotations.json') + + dashboard.new('slow_queries_and_annotations', import '../dev-dashboards/scenarios/slow_queries_and_annotations.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('table_pagination', import 'panel-table/table_pagination.json') + + dashboard.new('table_pagination', import '../dev-dashboards/panel-table/table_pagination.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('table_tests', import 'panel-table/table_tests.json') + + dashboard.new('table_tests', import '../dev-dashboards/panel-table/table_tests.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('table_tests_new', import 'panel-table/table_tests_new.json') + + dashboard.new('table_tests_new', import '../dev-dashboards/panel-table/table_tests_new.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('templating-dashboard-links-and-variables', import 'feature-templating/templating-dashboard-links-and-variables.json') + + dashboard.new('templating-dashboard-links-and-variables', import '../dev-dashboards/feature-templating/templating-dashboard-links-and-variables.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('templating-textbox-e2e-scenarios', import 'feature-templating/templating-textbox-e2e-scenarios.json') + + dashboard.new('templating-textbox-e2e-scenarios', import '../dev-dashboards/feature-templating/templating-textbox-e2e-scenarios.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-datalinks', import 'feature-templating/testdata-datalinks.json') + + dashboard.new('testdata-datalinks', import '../dev-dashboards/feature-templating/testdata-datalinks.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-nested-variables', import 'feature-templating/testdata-nested-variables.json') + + dashboard.new('testdata-nested-variables', import '../dev-dashboards/feature-templating/testdata-nested-variables.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-nested-variables-drilldown', import 'feature-templating/testdata-nested-variables-drilldown.json') + + dashboard.new('testdata-nested-variables-drilldown', import '../dev-dashboards/feature-templating/testdata-nested-variables-drilldown.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-repeating', import 'feature-templating/testdata-repeating.json') + + dashboard.new('testdata-repeating', import '../dev-dashboards/feature-templating/testdata-repeating.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-test-variable-output', import 'feature-templating/testdata-test-variable-output.json') + + dashboard.new('testdata-test-variable-output', import '../dev-dashboards/feature-templating/testdata-test-variable-output.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-variables-textbox', import 'feature-templating/testdata-variables-textbox.json') + + dashboard.new('testdata-variables-textbox', import '../dev-dashboards/feature-templating/testdata-variables-textbox.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata-variables-that-update-on-time-c', import 'feature-templating/testdata-variables-that-update-on-time-change.json') + + dashboard.new('testdata-variables-that-update-on-time-c', import '../dev-dashboards/feature-templating/testdata-variables-that-update-on-time-change.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('testdata_alerts', import 'alerting/testdata_alerts.json') + + dashboard.new('testdata_alerts', import '../dev-dashboards/alerting/testdata_alerts.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('text-options', import 'panel-text/text-options.json') + + dashboard.new('text-options', import '../dev-dashboards/panel-text/text-options.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('time_zone_support', import 'scenarios/time_zone_support.json') + + dashboard.new('time_zone_support', import '../dev-dashboards/scenarios/time_zone_support.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeline-demo', import 'panel-timeline/timeline-demo.json') + + dashboard.new('timeline-demo', import '../dev-dashboards/panel-timeline/timeline-demo.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeline-modes', import 'panel-timeline/timeline-modes.json') + + dashboard.new('timeline-modes', import '../dev-dashboards/panel-timeline/timeline-modes.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries', import 'panel-timeseries/timeseries.json') + + dashboard.new('timeseries', import '../dev-dashboards/panel-timeseries/timeseries.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-by-value-color-schemes', import 'panel-timeseries/timeseries-by-value-color-schemes.json') + + dashboard.new('timeseries-by-value-color-schemes', import '../dev-dashboards/panel-timeseries/timeseries-by-value-color-schemes.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-gradient-area', import 'panel-timeseries/timeseries-gradient-area.json') + + dashboard.new('timeseries-gradient-area', import '../dev-dashboards/panel-timeseries/timeseries-gradient-area.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-hue-gradients', import 'panel-timeseries/timeseries-hue-gradients.json') + + dashboard.new('timeseries-hue-gradients', import '../dev-dashboards/panel-timeseries/timeseries-hue-gradients.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-nulls', import 'panel-timeseries/timeseries-nulls.json') + + dashboard.new('timeseries-nulls', import '../dev-dashboards/panel-timeseries/timeseries-nulls.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-out-of-rage', import 'panel-timeseries/timeseries-out-of-rage.json') + + dashboard.new('timeseries-out-of-rage', import '../dev-dashboards/panel-timeseries/timeseries-out-of-rage.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-shared-tooltip-cursor-positio', import 'panel-timeseries/timeseries-shared-tooltip-cursor-position.json') + + dashboard.new('timeseries-shared-tooltip-cursor-positio', import '../dev-dashboards/panel-timeseries/timeseries-shared-tooltip-cursor-position.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-soft-limits', import 'panel-timeseries/timeseries-soft-limits.json') + + dashboard.new('timeseries-soft-limits', import '../dev-dashboards/panel-timeseries/timeseries-soft-limits.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-stacking', import 'panel-timeseries/timeseries-stacking.json') + + dashboard.new('timeseries-stacking', import '../dev-dashboards/panel-timeseries/timeseries-stacking.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-stacking2', import 'panel-timeseries/timeseries-stacking2.json') + + dashboard.new('timeseries-stacking2', import '../dev-dashboards/panel-timeseries/timeseries-stacking2.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-thresholds', import 'panel-timeseries/timeseries-thresholds.json') + + dashboard.new('timeseries-thresholds', import '../dev-dashboards/panel-timeseries/timeseries-thresholds.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-time', import 'panel-timeseries/timeseries-time.json') + + dashboard.new('timeseries-time', import '../dev-dashboards/panel-timeseries/timeseries-time.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-y-ticks-zero-decimals', import 'panel-timeseries/timeseries-y-ticks-zero-decimals.json') + + dashboard.new('timeseries-y-ticks-zero-decimals', import '../dev-dashboards/panel-timeseries/timeseries-y-ticks-zero-decimals.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { id: 0, } }, - dashboard.new('timeseries-yaxis-ticks', import 'panel-timeseries/timeseries-yaxis-ticks.json') + + dashboard.new('timeseries-yaxis-ticks', import '../dev-dashboards/panel-timeseries/timeseries-yaxis-ticks.json') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { diff --git a/devenv/jsonnet/gen.go b/devenv/jsonnet/gen.go new file mode 100644 index 00000000000..d83fd782275 --- /dev/null +++ b/devenv/jsonnet/gen.go @@ -0,0 +1,3 @@ +package jsonnet + +//go:generate go run dev-dashboards.go diff --git a/devenv/dev-dashboards/jsonnetfile.json b/devenv/jsonnet/jsonnetfile.json similarity index 100% rename from devenv/dev-dashboards/jsonnetfile.json rename to devenv/jsonnet/jsonnetfile.json diff --git a/devenv/dev-dashboards/jsonnetfile.lock.json b/devenv/jsonnet/jsonnetfile.lock.json similarity index 100% rename from devenv/dev-dashboards/jsonnetfile.lock.json rename to devenv/jsonnet/jsonnetfile.lock.json diff --git a/devenv/jsonnet/main.libsonnet b/devenv/jsonnet/main.libsonnet new file mode 100644 index 00000000000..5a584d43442 --- /dev/null +++ b/devenv/jsonnet/main.libsonnet @@ -0,0 +1 @@ +(import 'dev-dashboards.libsonnet') \ No newline at end of file diff --git a/devenv/dev-dashboards/tmpl/gen.libsonnet.tmpl b/devenv/jsonnet/tmpl/gen.libsonnet.tmpl similarity index 87% rename from devenv/dev-dashboards/tmpl/gen.libsonnet.tmpl rename to devenv/jsonnet/tmpl/gen.libsonnet.tmpl index 2e988009d62..c777d438f8c 100644 --- a/devenv/dev-dashboards/tmpl/gen.libsonnet.tmpl +++ b/devenv/jsonnet/tmpl/gen.libsonnet.tmpl @@ -9,7 +9,7 @@ local dashboard = grafana.dashboard; { folders: [grafana.folder.new('dev-dashboards', 'dev-dashboards')], dashboards: [{{range .Dashboards}} - dashboard.new('{{.Name}}', import '{{.Path}}') + + dashboard.new('{{.Name}}', import '../dev-dashboards/{{.Path}}') + resource.addMetadata('folder', 'dev-dashboards') + { spec+: { From bd87b46b1555929ba84acfc9dba5b9a37dc03e71 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Fri, 11 Nov 2022 09:27:35 +0000 Subject: [PATCH 194/926] Alerting: Improve test coverage for ConditionsCmd (#58603) --- pkg/expr/classic/classic_test.go | 533 +++++++++++++++++++---------- pkg/expr/classic/evaluator_test.go | 28 +- pkg/expr/classic/reduce_test.go | 279 +++++++-------- pkg/expr/mathexp/types.go | 4 - 4 files changed, 514 insertions(+), 330 deletions(-) diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index 19ff3a3d589..e5d85f9dd78 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/expr/mathexp" ) @@ -20,181 +20,61 @@ func TestConditionsCmd(t *testing.T) { vars mathexp.Vars expected func() mathexp.Results }{{ - name: "single query and single condition", + // This test asserts that a single query with condition returns 0 and no matches as the condition + // is not met + name: "single query with condition when condition is not met", vars: mathexp.Vars{ "A": mathexp.Results{ Values: []mathexp.Value{ - valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + newSeries(ptr.Float64(1), ptr.Float64(5)), }, }, }, cmd: &ConditionsCmd{ Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("avg"), - Operator: "and", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, - }, - }}, - expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(1)) - v.SetMeta([]EvalMatch{{Value: ptr.Float64(35)}}) - return mathexp.NewResults(v) - }, - }, { - name: "single query and single condition - empty series", - vars: mathexp.Vars{ - "A": mathexp.Results{ - Values: []mathexp.Value{ - valBasedSeries(), - }, - }, - }, - cmd: &ConditionsCmd{ - Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("avg"), - Operator: "and", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, - }, - }}, - expected: func() mathexp.Results { - v := valBasedNumber(nil) - v.SetMeta([]EvalMatch{{Metric: "NoData"}}) - return mathexp.NewResults(v) - }, - }, { - name: "single query and single condition - empty series and not empty series", - vars: mathexp.Vars{ - "A": mathexp.Results{ - Values: []mathexp.Value{ - valBasedSeries(), - valBasedSeries(ptr.Float64(3)), - }, - }, - }, - cmd: &ConditionsCmd{ - Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("avg"), - Operator: "and", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: .5}, - }, - }}, - expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(1)) - v.SetMeta([]EvalMatch{{Value: ptr.Float64(3)}}) - return mathexp.NewResults(v) - }, - }, { - name: "single query and two conditions", - vars: mathexp.Vars{ - "A": mathexp.Results{ - Values: []mathexp.Value{ - valBasedSeries(ptr.Float64(30), ptr.Float64(40)), - }, - }, - }, - cmd: &ConditionsCmd{ - Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("max"), - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, - }, { InputRefID: "A", Reducer: reducer("min"), - Operator: "or", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 12}, - }, - }}, - expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(1)) - v.SetMeta([]EvalMatch{{Value: ptr.Float64(40)}, {Value: ptr.Float64(30)}}) - return mathexp.NewResults(v) - }, - }, { - name: "single query and single condition - multiple series (one true, one not == true)", - vars: mathexp.Vars{ - "A": mathexp.Results{ - Values: []mathexp.Value{ - valBasedSeriesWithLabels(data.Labels{"h": "1"}, ptr.Float64(30), ptr.Float64(40)), - valBasedSeries(ptr.Float64(0), ptr.Float64(10)), - }, - }, - }, - cmd: &ConditionsCmd{ - Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("avg"), Operator: "and", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 2}, }, }}, expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(1)) - v.SetMeta([]EvalMatch{{Value: ptr.Float64(35), Labels: data.Labels{"h": "1"}}}) - return mathexp.NewResults(v) - }, - }, { - name: "single query and single condition - multiple series (one not true, one true == true)", - vars: mathexp.Vars{ - "A": mathexp.Results{ - Values: []mathexp.Value{ - valBasedSeries(ptr.Float64(0), ptr.Float64(10)), - valBasedSeries(ptr.Float64(30), ptr.Float64(40)), - }, - }, - }, - cmd: &ConditionsCmd{ - Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("avg"), - Operator: "and", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, - }, - }}, - expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(1)) - v.SetMeta([]EvalMatch{{Value: ptr.Float64(35)}}) - return mathexp.NewResults(v) - }, - }, { - name: "single query and single condition - multiple series (2 not true == false)", - vars: mathexp.Vars{ - "A": mathexp.Results{ - Values: []mathexp.Value{ - valBasedSeries(ptr.Float64(0), ptr.Float64(10)), - valBasedSeries(ptr.Float64(20), ptr.Float64(30)), - }, - }, - }, - cmd: &ConditionsCmd{ - Conditions: []condition{ - { - InputRefID: "A", - Reducer: reducer("avg"), - Operator: "and", - Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 34}, - }, - }}, - expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(0)) + v := newNumber(ptr.Float64(0)) v.SetMeta([]EvalMatch{}) - return mathexp.Results{Values: mathexp.Values{v}} + return newResults(v) }, }, { - name: "single query and single ranged condition", + // This test asserts that a single query with condition returns 1 and the average in the meta as + // the condition is met + name: "single query with condition when condition is met", vars: mathexp.Vars{ "A": mathexp.Results{ Values: []mathexp.Value{ - valBasedSeries(ptr.Float64(30), ptr.Float64(40)), + newSeries(ptr.Float64(1), ptr.Float64(5)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("avg"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 2}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(3)}}) + return newResults(v) + }, + }, { + name: "single query with ranged condition when condition is not met", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), }, }, }, @@ -204,17 +84,41 @@ func TestConditionsCmd(t *testing.T) { InputRefID: "A", Reducer: reducer("diff"), Operator: "and", - Evaluator: &rangedEvaluator{Type: "within_range", Lower: 2, Upper: 3}, + Evaluator: &rangedEvaluator{Type: "within_range", Lower: 2, Upper: 4}, }, }, }, expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(0)) + v := newNumber(ptr.Float64(0)) v.SetMeta([]EvalMatch{}) - return mathexp.NewResults(v) + return newResults(v) }, }, { - name: "single query with no data", + name: "single query with ranged condition when condition is met", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("diff"), + Operator: "and", + Evaluator: &rangedEvaluator{Type: "within_range", Lower: 0, Upper: 10}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(4)}}) + return newResults(v) + }, + }, { + name: "single no data query with condition is No Data", vars: mathexp.Vars{ "A": mathexp.Results{ Values: []mathexp.Value{mathexp.NoData{}.New()}, @@ -224,19 +128,19 @@ func TestConditionsCmd(t *testing.T) { Conditions: []condition{ { InputRefID: "A", - Reducer: reducer("avg"), + Reducer: reducer("min"), Operator: "and", Evaluator: &thresholdEvaluator{"gt", 1}, }, }, }, expected: func() mathexp.Results { - v := valBasedNumber(nil) + v := newNumber(nil) v.SetMeta([]EvalMatch{{Metric: "NoData"}}) - return mathexp.NewResults(v) + return newResults(v) }, }, { - name: "single query with no values", + name: "single no values query with condition is No Data", vars: mathexp.Vars{ "A": mathexp.Results{ Values: []mathexp.Value{}, @@ -246,25 +150,307 @@ func TestConditionsCmd(t *testing.T) { Conditions: []condition{ { InputRefID: "A", - Reducer: reducer("avg"), + Reducer: reducer("min"), Operator: "and", Evaluator: &thresholdEvaluator{"gt", 1}, }, }, }, expected: func() mathexp.Results { - v := valBasedNumber(nil) + v := newNumber(nil) v.SetMeta([]EvalMatch{{Metric: "NoData"}}) - return mathexp.NewResults(v) + return newResults(v) }, }, { - name: "should accept numbers", + name: "single series no points query with condition returns No Data", vars: mathexp.Vars{ "A": mathexp.Results{ Values: []mathexp.Value{ - valBasedNumber(ptr.Float64(5)), - valBasedNumber(ptr.Float64(10)), - valBasedNumber(ptr.Float64(15)), + newSeries(nil), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Metric: "NoData"}}) + return newResults(v) + }, + }, { + name: "single no data query with condition is met has no value", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &noValueEvaluator{}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + // This seems incorrect + v.SetMeta([]EvalMatch{{}, {Metric: "NoData"}}) + return newResults(v) + }, + }, { + name: "single no values query with condition is met has no value", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &noValueEvaluator{}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + // This too seems incorrect, looks like we don't call the evaluator + v.SetMeta([]EvalMatch{{Metric: "NoData"}}) + return newResults(v) + }, + }, { + name: "single series no points query with condition is met has no value", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(nil), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &noValueEvaluator{}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + // This seems incorrect + v.SetMeta([]EvalMatch{{}, {Metric: "NoData"}}) + return newResults(v) + }, + }, { + // This test asserts that a single query with condition returns 1 and the average of the second + // series in the meta because while the first series is No Data the second series contains valid points + name: "single query with condition returns average when one series is no data and the other contains valid points", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(), + newSeries(ptr.Float64(2)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 1}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(2)}}) + return newResults(v) + }, + }, { + name: "single query with condition and no series matches condition", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + newSeries(ptr.Float64(2), ptr.Float64(10)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 15}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(0)) + v.SetMeta([]EvalMatch{}) + return mathexp.Results{Values: mathexp.Values{v}} + }, + }, { + name: "single query with condition and one of two series matches condition", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + newSeriesWithLabels(data.Labels{"foo": "bar"}, ptr.Float64(2), ptr.Float64(10)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 1}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(2), Labels: data.Labels{"foo": "bar"}}}) + return newResults(v) + }, + }, { + name: "single query with condition and both series matches condition", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + newSeriesWithLabels(data.Labels{"foo": "bar"}, ptr.Float64(2), ptr.Float64(10)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 0}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{ + Value: ptr.Float64(1), + }, { + Value: ptr.Float64(2), + Labels: data.Labels{"foo": "bar"}, + }}) + return newResults(v) + }, + }, { + name: "single query with two conditions where left hand side is met", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("max"), + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 2}, + }, + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 1}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}}) + return newResults(v) + }, + }, { + name: "single query with two conditions where right hand side is met", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("max"), + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 10}, + }, + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 0}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(1)}}) + return newResults(v) + }, + }, { + name: "single query with two conditions where both are met", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newSeries(ptr.Float64(1), ptr.Float64(5)), + }, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("max"), + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 2}, + }, + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{Type: "gt", Threshold: 0}, + }, + }}, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}, {Value: ptr.Float64(1)}}) + return newResults(v) + }, + }, { + name: "single instant query with condition where condition is met", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{ + newNumber(ptr.Float64(5)), + newNumber(ptr.Float64(10)), + newNumber(ptr.Float64(15)), }, }, }, @@ -279,13 +465,13 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := valBasedNumber(ptr.Float64(1)) + v := newNumber(ptr.Float64(1)) v.SetMeta([]EvalMatch{ {Value: ptr.Float64(5)}, {Value: ptr.Float64(10)}, {Value: ptr.Float64(15)}, }) - return mathexp.NewResults(v) + return newResults(v) }, }} @@ -293,9 +479,6 @@ func TestConditionsCmd(t *testing.T) { t.Run(tt.name, func(t *testing.T) { res, err := tt.cmd.Execute(context.Background(), time.Now(), tt.vars) require.NoError(t, err) - - require.Equal(t, 1, len(res.Values)) - require.Equal(t, tt.expected(), res) }) } diff --git a/pkg/expr/classic/evaluator_test.go b/pkg/expr/classic/evaluator_test.go index b4a61e98441..4deb92491ad 100644 --- a/pkg/expr/classic/evaluator_test.go +++ b/pkg/expr/classic/evaluator_test.go @@ -18,25 +18,25 @@ func TestThresholdEvaluator(t *testing.T) { { name: "value 3 is gt 1: true", evaluator: &thresholdEvaluator{"gt", 1}, - inputNumber: valBasedNumber(ptr.Float64(3)), + inputNumber: newNumber(ptr.Float64(3)), expected: true, }, { name: "value 1 is gt 3: false", evaluator: &thresholdEvaluator{"gt", 3}, - inputNumber: valBasedNumber(ptr.Float64(1)), + inputNumber: newNumber(ptr.Float64(1)), expected: false, }, { name: "value 3 is lt 1: true", evaluator: &thresholdEvaluator{"lt", 1}, - inputNumber: valBasedNumber(ptr.Float64(3)), + inputNumber: newNumber(ptr.Float64(3)), expected: false, }, { name: "value 1 is lt 3: false", evaluator: &thresholdEvaluator{"lt", 3}, - inputNumber: valBasedNumber(ptr.Float64(1)), + inputNumber: newNumber(ptr.Float64(1)), expected: true, }, } @@ -59,50 +59,50 @@ func TestRangedEvaluator(t *testing.T) { { name: "value 3 is within range 1, 100: true", evaluator: &rangedEvaluator{"within_range", 1, 100}, - inputNumber: valBasedNumber(ptr.Float64(3)), + inputNumber: newNumber(ptr.Float64(3)), expected: true, }, { name: "value 300 is within range 1, 100: false", evaluator: &rangedEvaluator{"within_range", 1, 100}, - inputNumber: valBasedNumber(ptr.Float64(300)), + inputNumber: newNumber(ptr.Float64(300)), expected: false, }, { name: "value 3 is within range 100, 1: true", evaluator: &rangedEvaluator{"within_range", 100, 1}, - inputNumber: valBasedNumber(ptr.Float64(3)), + inputNumber: newNumber(ptr.Float64(3)), expected: true, }, { name: "value 300 is within range 100, 1: false", evaluator: &rangedEvaluator{"within_range", 100, 1}, - inputNumber: valBasedNumber(ptr.Float64(300)), + inputNumber: newNumber(ptr.Float64(300)), expected: false, }, // outside { name: "value 1000 is outside range 1, 100: true", evaluator: &rangedEvaluator{"outside_range", 1, 100}, - inputNumber: valBasedNumber(ptr.Float64(1000)), + inputNumber: newNumber(ptr.Float64(1000)), expected: true, }, { name: "value 50 is outside range 1, 100: false", evaluator: &rangedEvaluator{"outside_range", 1, 100}, - inputNumber: valBasedNumber(ptr.Float64(50)), + inputNumber: newNumber(ptr.Float64(50)), expected: false, }, { name: "value 1000 is outside range 100, 1: true", evaluator: &rangedEvaluator{"outside_range", 100, 1}, - inputNumber: valBasedNumber(ptr.Float64(1000)), + inputNumber: newNumber(ptr.Float64(1000)), expected: true, }, { name: "value 50 is outside range 100, 1: false", evaluator: &rangedEvaluator{"outside_range", 100, 1}, - inputNumber: valBasedNumber(ptr.Float64(50)), + inputNumber: newNumber(ptr.Float64(50)), expected: false, }, } @@ -124,13 +124,13 @@ func TestNoValueEvaluator(t *testing.T) { { name: "value 50 is no_value: false", evaluator: &noValueEvaluator{}, - inputNumber: valBasedNumber(ptr.Float64(50)), + inputNumber: newNumber(ptr.Float64(50)), expected: false, }, { name: "value nil is no_value: true", evaluator: &noValueEvaluator{}, - inputNumber: valBasedNumber(nil), + inputNumber: newNumber(nil), expected: true, }, } diff --git a/pkg/expr/classic/reduce_test.go b/pkg/expr/classic/reduce_test.go index 8e146215a2b..98cb5e63718 100644 --- a/pkg/expr/classic/reduce_test.go +++ b/pkg/expr/classic/reduce_test.go @@ -5,10 +5,11 @@ import ( "testing" "time" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/expr/mathexp" "github.com/stretchr/testify/require" ptr "github.com/xorcare/pointer" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/expr/mathexp" ) func TestReducer(t *testing.T) { @@ -21,98 +22,98 @@ func TestReducer(t *testing.T) { { name: "sum", reducer: reducer("sum"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), - expectedNumber: valBasedNumber(ptr.Float64(6)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: newNumber(ptr.Float64(6)), }, { name: "min", reducer: reducer("min"), - inputSeries: valBasedSeries(ptr.Float64(3), ptr.Float64(2), ptr.Float64(1)), - expectedNumber: valBasedNumber(ptr.Float64(1)), + inputSeries: newSeries(ptr.Float64(3), ptr.Float64(2), ptr.Float64(1)), + expectedNumber: newNumber(ptr.Float64(1)), }, { name: "min with NaNs only", reducer: reducer("min"), - inputSeries: valBasedSeries(ptr.Float64(math.NaN()), ptr.Float64(math.NaN()), ptr.Float64(math.NaN())), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(ptr.Float64(math.NaN()), ptr.Float64(math.NaN()), ptr.Float64(math.NaN())), + expectedNumber: newNumber(nil), }, { name: "max", reducer: reducer("max"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), - expectedNumber: valBasedNumber(ptr.Float64(3)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: newNumber(ptr.Float64(3)), }, { name: "count", reducer: reducer("count"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), - expectedNumber: valBasedNumber(ptr.Float64(3)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), + expectedNumber: newNumber(ptr.Float64(3)), }, { name: "last", reducer: reducer("last"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), - expectedNumber: valBasedNumber(ptr.Float64(3000)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), + expectedNumber: newNumber(ptr.Float64(3000)), }, { name: "median with odd amount of numbers", reducer: reducer("median"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), - expectedNumber: valBasedNumber(ptr.Float64(2)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3000)), + expectedNumber: newNumber(ptr.Float64(2)), }, { name: "median with even amount of numbers", reducer: reducer("median"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(4), ptr.Float64(3000)), - expectedNumber: valBasedNumber(ptr.Float64(3)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(4), ptr.Float64(3000)), + expectedNumber: newNumber(ptr.Float64(3)), }, { name: "median with one value", reducer: reducer("median"), - inputSeries: valBasedSeries(ptr.Float64(1)), - expectedNumber: valBasedNumber(ptr.Float64(1)), + inputSeries: newSeries(ptr.Float64(1)), + expectedNumber: newNumber(ptr.Float64(1)), }, { name: "median should ignore null values", reducer: reducer("median"), - inputSeries: valBasedSeries(nil, nil, nil, ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), - expectedNumber: valBasedNumber(ptr.Float64(2)), + inputSeries: newSeries(nil, nil, nil, ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: newNumber(ptr.Float64(2)), }, { name: "avg", reducer: reducer("avg"), - inputSeries: valBasedSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), - expectedNumber: valBasedNumber(ptr.Float64(2)), + inputSeries: newSeries(ptr.Float64(1), ptr.Float64(2), ptr.Float64(3)), + expectedNumber: newNumber(ptr.Float64(2)), }, { name: "avg with only nulls", reducer: reducer("avg"), - inputSeries: valBasedSeries(nil), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(nil), + expectedNumber: newNumber(nil), }, { name: "avg of number values and null values should ignore nulls", reducer: reducer("avg"), - inputSeries: valBasedSeries(ptr.Float64(3), nil, nil, ptr.Float64(3)), - expectedNumber: valBasedNumber(ptr.Float64(3)), + inputSeries: newSeries(ptr.Float64(3), nil, nil, ptr.Float64(3)), + expectedNumber: newNumber(ptr.Float64(3)), }, { name: "count_non_null with mixed null/real values", reducer: reducer("count_non_null"), - inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), - expectedNumber: valBasedNumber(ptr.Float64(2)), + inputSeries: newSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), + expectedNumber: newNumber(ptr.Float64(2)), }, { name: "count_non_null with mixed null/real values", reducer: reducer("count_non_null"), - inputSeries: valBasedSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), - expectedNumber: valBasedNumber(ptr.Float64(2)), + inputSeries: newSeries(nil, nil, ptr.Float64(3), ptr.Float64(4)), + expectedNumber: newNumber(ptr.Float64(2)), }, { name: "count_non_null with no values", reducer: reducer("count_non_null"), - inputSeries: valBasedSeries(nil, nil), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(nil, nil), + expectedNumber: newNumber(nil), }, } @@ -133,58 +134,58 @@ func TestDiffReducer(t *testing.T) { }{ { name: "diff of one positive point", - inputSeries: valBasedSeries(ptr.Float64(30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "diff of one negative point", - inputSeries: valBasedSeries(ptr.Float64(-30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(-30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "diff two positive points [1]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(10)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(10)), }, { name: "diff two positive points [2]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), - expectedNumber: valBasedNumber(ptr.Float64(-10)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: newNumber(ptr.Float64(-10)), }, { name: "diff two negative points [1]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(-10)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(-10)), }, { name: "diff two negative points [2]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), - expectedNumber: valBasedNumber(ptr.Float64(20)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: newNumber(ptr.Float64(20)), }, { name: "diff of one positive and one negative point", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(-70)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(-70)), }, { name: "diff of one negative and one positive point", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(70)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(70)), }, { name: "diff of three positive points", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), - expectedNumber: valBasedNumber(ptr.Float64(20)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: newNumber(ptr.Float64(20)), }, { name: "diff of three negative points", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), - expectedNumber: valBasedNumber(ptr.Float64(-20)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: newNumber(ptr.Float64(-20)), }, { name: "diff with only nulls", - inputSeries: valBasedSeries(nil, nil), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(nil, nil), + expectedNumber: newNumber(nil), }, } for _, tt := range tests { @@ -203,58 +204,58 @@ func TestDiffAbsReducer(t *testing.T) { }{ { name: "diff_abs of one positive point", - inputSeries: valBasedSeries(ptr.Float64(30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "diff_abs of one negative point", - inputSeries: valBasedSeries(ptr.Float64(-30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(-30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "diff_abs two positive points [1]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(10)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(10)), }, { name: "diff_abs two positive points [2]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), - expectedNumber: valBasedNumber(ptr.Float64(10)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: newNumber(ptr.Float64(10)), }, { name: "diff_abs two negative points [1]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(10)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(10)), }, { name: "diff_abs two negative points [2]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), - expectedNumber: valBasedNumber(ptr.Float64(20)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: newNumber(ptr.Float64(20)), }, { name: "diff_abs of one positive and one negative point", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(70)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(70)), }, { name: "diff_abs of one negative and one positive point", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(70)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(70)), }, { name: "diff_abs of three positive points", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), - expectedNumber: valBasedNumber(ptr.Float64(20)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: newNumber(ptr.Float64(20)), }, { name: "diff_abs of three negative points", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), - expectedNumber: valBasedNumber(ptr.Float64(20)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: newNumber(ptr.Float64(20)), }, { name: "diff_abs with only nulls", - inputSeries: valBasedSeries(nil, nil), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(nil, nil), + expectedNumber: newNumber(nil), }, } for _, tt := range tests { @@ -273,58 +274,58 @@ func TestPercentDiffReducer(t *testing.T) { }{ { name: "percent_diff of one positive point", - inputSeries: valBasedSeries(ptr.Float64(30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "percent_diff of one negative point", - inputSeries: valBasedSeries(ptr.Float64(-30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(-30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "percent_diff two positive points [1]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(33.33333333333333)), }, { name: "percent_diff two positive points [2]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), - expectedNumber: valBasedNumber(ptr.Float64(-33.33333333333333)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: newNumber(ptr.Float64(-33.33333333333333)), }, { name: "percent_diff two negative points [1]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(-33.33333333333333)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(-33.33333333333333)), }, { name: "percent_diff two negative points [2]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), - expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: newNumber(ptr.Float64(66.66666666666666)), }, { name: "percent_diff of one positive and one negative point", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(-233.33333333333334)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(-233.33333333333334)), }, { name: "percent_diff of one negative and one positive point", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(233.33333333333334)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(233.33333333333334)), }, { name: "percent_diff of three positive points", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), - expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: newNumber(ptr.Float64(66.66666666666666)), }, { name: "percent_diff of three negative points", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), - expectedNumber: valBasedNumber(ptr.Float64(-66.66666666666666)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: newNumber(ptr.Float64(-66.66666666666666)), }, { name: "percent_diff with only nulls", - inputSeries: valBasedSeries(nil, nil), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(nil, nil), + expectedNumber: newNumber(nil), }, } for _, tt := range tests { @@ -343,58 +344,58 @@ func TestPercentDiffAbsReducer(t *testing.T) { }{ { name: "percent_diff_abs of one positive point", - inputSeries: valBasedSeries(ptr.Float64(30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "percent_diff_abs of one negative point", - inputSeries: valBasedSeries(ptr.Float64(-30)), - expectedNumber: valBasedNumber(ptr.Float64(0)), + inputSeries: newSeries(ptr.Float64(-30)), + expectedNumber: newNumber(ptr.Float64(0)), }, { name: "percent_diff_abs two positive points [1]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(33.33333333333333)), }, { name: "percent_diff_abs two positive points [2]", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(20)), - expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(20)), + expectedNumber: newNumber(ptr.Float64(33.33333333333333)), }, { name: "percent_diff_abs two negative points [1]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(33.33333333333333)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(33.33333333333333)), }, { name: "percent_diff_abs two negative points [2]", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-10)), - expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-10)), + expectedNumber: newNumber(ptr.Float64(66.66666666666666)), }, { name: "percent_diff_abs of one positive and one negative point", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(-40)), - expectedNumber: valBasedNumber(ptr.Float64(233.33333333333334)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(-40)), + expectedNumber: newNumber(ptr.Float64(233.33333333333334)), }, { name: "percent_diff_abs of one negative and one positive point", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(40)), - expectedNumber: valBasedNumber(ptr.Float64(233.33333333333334)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(40)), + expectedNumber: newNumber(ptr.Float64(233.33333333333334)), }, { name: "percent_diff_abs of three positive points", - inputSeries: valBasedSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), - expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + inputSeries: newSeries(ptr.Float64(30), ptr.Float64(40), ptr.Float64(50)), + expectedNumber: newNumber(ptr.Float64(66.66666666666666)), }, { name: "percent_diff_abs of three negative points", - inputSeries: valBasedSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), - expectedNumber: valBasedNumber(ptr.Float64(66.66666666666666)), + inputSeries: newSeries(ptr.Float64(-30), ptr.Float64(-40), ptr.Float64(-50)), + expectedNumber: newNumber(ptr.Float64(66.66666666666666)), }, { name: "percent_diff_abs with only nulls", - inputSeries: valBasedSeries(nil, nil), - expectedNumber: valBasedNumber(nil), + inputSeries: newSeries(nil, nil), + expectedNumber: newNumber(nil), }, } for _, tt := range tests { @@ -405,24 +406,28 @@ func TestPercentDiffAbsReducer(t *testing.T) { } } -func valBasedSeries(vals ...*float64) mathexp.Series { - newSeries := mathexp.NewSeries("", nil, len(vals)) - for idx, f := range vals { - newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f) - } - return newSeries +func newNumber(f *float64) mathexp.Number { + num := mathexp.NewNumber("", nil) + num.SetValue(f) + return num } -func valBasedSeriesWithLabels(l data.Labels, vals ...*float64) mathexp.Series { - newSeries := mathexp.NewSeries("", l, len(vals)) - for idx, f := range vals { - newSeries.SetPoint(idx, time.Unix(int64(idx), 0), f) +func newSeries(points ...*float64) mathexp.Series { + series := mathexp.NewSeries("", nil, len(points)) + for idx, point := range points { + series.SetPoint(idx, time.Unix(int64(idx), 0), point) } - return newSeries + return series } -func valBasedNumber(f *float64) mathexp.Number { - newNumber := mathexp.NewNumber("", nil) - newNumber.SetValue(f) - return newNumber +func newSeriesWithLabels(labels data.Labels, values ...*float64) mathexp.Series { + series := mathexp.NewSeries("", labels, len(values)) + for idx, value := range values { + series.SetPoint(idx, time.Unix(int64(idx), 0), value) + } + return series +} + +func newResults(values ...mathexp.Value) mathexp.Results { + return mathexp.Results{Values: values} } diff --git a/pkg/expr/mathexp/types.go b/pkg/expr/mathexp/types.go index 8c668e8b8ee..3e020d85d5a 100644 --- a/pkg/expr/mathexp/types.go +++ b/pkg/expr/mathexp/types.go @@ -11,10 +11,6 @@ type Results struct { Values Values } -func NewResults(values ...Value) Results { - return Results{Values: values} -} - // Values is a slice of Value interfaces type Values []Value From 02137396bd5a586880d44300ecc2f25dbebb83b4 Mon Sep 17 00:00:00 2001 From: Timur Olzhabayev Date: Fri, 11 Nov 2022 11:18:38 +0100 Subject: [PATCH 195/926] Fix: Bumping `msw` and `xmldom` (#58627) Bumping msw and xmldom to mitigate xmldom vulnerability --- package.json | 2 +- yarn.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 10cafee0435..f9b734b7e86 100644 --- a/package.json +++ b/package.json @@ -205,7 +205,7 @@ "lerna": "5.5.4", "lint-staged": "13.0.3", "mini-css-extract-plugin": "2.6.1", - "msw": "0.47.4", + "msw": "0.48.1", "mutationobserver-shim": "0.3.7", "ngtemplate-loader": "2.1.0", "node-notifier": "10.0.1", diff --git a/yarn.lock b/yarn.lock index faa565e44dd..cf67ffc6d1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12802,9 +12802,9 @@ __metadata: linkType: hard "@xmldom/xmldom@npm:^0.8.3": - version: 0.8.3 - resolution: "@xmldom/xmldom@npm:0.8.3" - checksum: 087303060fc794f0ec8c0e9031362c1ae56eb1cf06b291cc178602462c072fb9f84657a430ae9c8c1b4c0d33549259e89ed14ebc86ca1a696f253bdd7f0dffcf + version: 0.8.6 + resolution: "@xmldom/xmldom@npm:0.8.6" + checksum: f17ac6d99a971a6aeb831fcfc5cfa86f367664e45815046548814b2deb17ccc421fef4e0d5ba29e66179d112b552f6caa5680064f8e7bd8a389b788a60404c8e languageName: node linkType: hard @@ -21719,7 +21719,7 @@ __metadata: mousetrap: 1.6.5 mousetrap-global-bind: 1.1.0 moveable: 0.37.1 - msw: 0.47.4 + msw: 0.48.1 mutationobserver-shim: 0.3.7 ngtemplate-loader: 2.1.0 node-notifier: 10.0.1 @@ -27958,9 +27958,9 @@ __metadata: languageName: node linkType: hard -"msw@npm:0.47.4": - version: 0.47.4 - resolution: "msw@npm:0.47.4" +"msw@npm:0.48.1": + version: 0.48.1 + resolution: "msw@npm:0.48.1" dependencies: "@mswjs/cookies": ^0.2.2 "@mswjs/interceptors": ^0.17.5 @@ -27989,7 +27989,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: 10ff632641d40384d6622abf4df6399e4ae649db0f676b5d1ee2d0a515ec96f33abe9d4fecba08cdba4b2e43255af419da9eefc020d40a7e10669d0906457197 + checksum: fbe255fd7d97058663f4807c570dadd3c83948f3d2675c13932ca22346a2c2a15c4e3681af7408ca96efba258223b933ad2aaed3c881a354118dbd8174d697ba languageName: node linkType: hard From c76183a961edd87eb1c620e327bb0aa4ea930041 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous <90795735+joey-grafana@users.noreply.github.com> Date: Fri, 11 Nov 2022 10:40:13 +0000 Subject: [PATCH 196/926] Tempo: Fix search removing service name from query (#58630) * Fix handleOnChange deps * Remove eslint disable --- .../datasource/tempo/QueryEditor/NativeSearch.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx index 6d8a1fb7c88..79703c2e7e0 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx @@ -160,12 +160,15 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props } }; - const handleOnChange = useCallback((value) => { - onChange({ - ...query, - search: value, - }); - }, []); // eslint-disable-line + const handleOnChange = useCallback( + (value) => { + onChange({ + ...query, + search: value, + }); + }, + [onChange, query] + ); const templateSrv: TemplateSrv = getTemplateSrv(); From 8e4fa4046bcf373ca52a0bd382ed00664fd4bb81 Mon Sep 17 00:00:00 2001 From: Hamas Shafiq Date: Fri, 11 Nov 2022 10:44:06 +0000 Subject: [PATCH 197/926] Chore: Refactor process.js & process.test.js to TypeScript (#58464) --- .../src/selectors/{process.test.js => process.test.ts} | 8 +++----- .../src/selectors/{process.js => process.ts} | 6 ++++-- 2 files changed, 7 insertions(+), 7 deletions(-) rename packages/jaeger-ui-components/src/selectors/{process.test.js => process.test.ts} (81%) rename packages/jaeger-ui-components/src/selectors/{process.js => process.ts} (75%) diff --git a/packages/jaeger-ui-components/src/selectors/process.test.js b/packages/jaeger-ui-components/src/selectors/process.test.ts similarity index 81% rename from packages/jaeger-ui-components/src/selectors/process.test.js rename to packages/jaeger-ui-components/src/selectors/process.test.ts index 9edc171fd23..d3928edfe43 100644 --- a/packages/jaeger-ui-components/src/selectors/process.test.js +++ b/packages/jaeger-ui-components/src/selectors/process.test.ts @@ -11,21 +11,19 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. - import traceGenerator from '../demo/trace-generators'; +import { TraceProcess } from '../types/trace'; import * as processSelectors from './process'; const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 }); it('getProcessServiceName() should return the serviceName of the process', () => { - const proc = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]]; - + const proc: TraceProcess = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]]; expect(processSelectors.getProcessServiceName(proc)).toBe(proc.serviceName); }); it('getProcessTags() should return the tags on the process', () => { - const proc = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]]; - + const proc: TraceProcess = generatedTrace.processes[Object.keys(generatedTrace.processes)[0]]; expect(processSelectors.getProcessTags(proc)).toBe(proc.tags); }); diff --git a/packages/jaeger-ui-components/src/selectors/process.js b/packages/jaeger-ui-components/src/selectors/process.ts similarity index 75% rename from packages/jaeger-ui-components/src/selectors/process.js rename to packages/jaeger-ui-components/src/selectors/process.ts index 11807c8fe3f..d8d1a021630 100644 --- a/packages/jaeger-ui-components/src/selectors/process.js +++ b/packages/jaeger-ui-components/src/selectors/process.ts @@ -12,5 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -export const getProcessServiceName = (proc) => proc.serviceName; -export const getProcessTags = (proc) => proc.tags; +import { TraceProcess } from '../types/trace'; + +export const getProcessServiceName = (proc: TraceProcess) => proc.serviceName; +export const getProcessTags = (proc: TraceProcess) => proc.tags; From 891ae91c7089238ca70a6753fe36c302e97cd548 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 11 Nov 2022 12:30:26 +0100 Subject: [PATCH 198/926] docs: fix typo in provisioning docs (#58110) Fix typo Co-authored-by: Udlei Nati --- conf/provisioning/alerting/sample.yaml | 2 +- .../provision-alerting-resources/file-provisioning/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/provisioning/alerting/sample.yaml b/conf/provisioning/alerting/sample.yaml index f050d1b51d2..2a86bf5ea71 100644 --- a/conf/provisioning/alerting/sample.yaml +++ b/conf/provisioning/alerting/sample.yaml @@ -133,7 +133,7 @@ apiVersion: 1 # # How long to wait before sending a notification about new alerts that # # are added to a group of alerts for which an initial notification has # # already been sent. (Usually ~5m or more), default = 5m -# group_internval: 5m +# group_interval: 5m # # How long to wait before sending a notification again if it has already # # been sent successfully for an alert. (Usually ~3h or more), default = 4h # repeat_interval: 4h diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index 71979ebf3ba..5312f32b173 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -536,7 +536,7 @@ policies: # How long to wait before sending a notification about new alerts that # are added to a group of alerts for which an initial notification has # already been sent. (Usually ~5m or more), default = 5m - group_internval: 5m + group_interval: 5m # How long to wait before sending a notification again if it has already # been sent successfully for an alert. (Usually ~3h or more), default = 4h repeat_interval: 4h From 0a9129cf90a7134411f8aeca1c0bd56b112e9371 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 11 Nov 2022 11:34:00 +0000 Subject: [PATCH 199/926] Chore: move to node 18 (#58570) * bump node version to 18 * update folder to 18.x * update README for m1 instructions * update drone * update unit test * update README --- .drone.yml | 384 +++++++++--------- Dockerfile | 2 +- Dockerfile.ubuntu | 2 +- contribute/developer-guide.md | 2 +- package.json | 2 +- .../SharePublicDashboard.test.tsx | 3 +- scripts/build/ci-build/Dockerfile | 4 +- scripts/build/ci-build/README.md | 2 + scripts/drone/steps/lib.star | 2 +- 9 files changed, 203 insertions(+), 200 deletions(-) diff --git a/.drone.yml b/.drone.yml index 1979761236f..f3b3d4565e6 100644 --- a/.drone.yml +++ b/.drone.yml @@ -80,13 +80,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -94,7 +94,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-frontend trigger: event: @@ -135,7 +135,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn run prettier:check @@ -146,7 +146,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: lint-frontend trigger: event: @@ -200,7 +200,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -208,25 +208,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend-integration trigger: event: @@ -278,7 +278,7 @@ steps: - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update && apt-get install make @@ -348,7 +348,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -356,18 +356,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" @@ -392,7 +392,7 @@ steps: from_secret: github_token_pr TEST_TAG: v0.0.0-test failure: ignore - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: trigger-test-release when: paths: @@ -419,7 +419,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -428,7 +428,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -437,7 +437,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -445,7 +445,7 @@ steps: - compile-build-cmd - yarn-install environment: null - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-plugins - commands: - . scripts/build/gpg-test-vars.sh && ./bin/build package --jobs 8 --edition oss @@ -456,7 +456,7 @@ steps: - build-frontend - build-frontend-packages environment: null - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package - commands: - ./scripts/grafana-server/start-server @@ -469,7 +469,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: grafana-server - commands: - apt-get install -y netcat @@ -571,7 +571,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-storybook when: paths: @@ -582,7 +582,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -682,7 +682,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -690,13 +690,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update @@ -712,7 +712,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: postgres-integration-tests - commands: - apt-get update @@ -728,7 +728,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: mysql-integration-tests trigger: event: @@ -784,7 +784,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - |- @@ -796,7 +796,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: codespell - commands: - yarn run prettier:checkDocs @@ -804,7 +804,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -852,7 +852,7 @@ steps: - ./bin/build shellcheck depends_on: - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: shellcheck trigger: event: @@ -897,7 +897,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - |- @@ -909,7 +909,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: codespell - commands: - yarn run prettier:checkDocs @@ -917,7 +917,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -968,13 +968,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -982,7 +982,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-frontend trigger: branch: main @@ -1020,7 +1020,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn run prettier:check @@ -1031,7 +1031,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: lint-frontend trigger: branch: main @@ -1082,7 +1082,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1090,25 +1090,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend-integration trigger: branch: main @@ -1153,7 +1153,7 @@ steps: - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update && apt-get install make @@ -1223,7 +1223,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1231,25 +1231,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - ./bin/build build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1258,7 +1258,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1267,7 +1267,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1277,7 +1277,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -1295,7 +1295,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package - commands: - ./scripts/grafana-server/start-server @@ -1308,7 +1308,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: grafana-server - commands: - apt-get install -y netcat @@ -1410,7 +1410,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-storybook when: paths: @@ -1421,7 +1421,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -1465,7 +1465,7 @@ steps: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key failure: ignore - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: publish-frontend-metrics when: repo: @@ -1546,7 +1546,7 @@ steps: environment: NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: release-canary-npm-packages when: repo: @@ -1655,7 +1655,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1663,13 +1663,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update @@ -1685,7 +1685,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: postgres-integration-tests - commands: - apt-get update @@ -1701,7 +1701,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: mysql-integration-tests trigger: branch: main @@ -1935,18 +1935,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -1960,7 +1960,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss ${DRONE_TAG} @@ -1969,7 +1969,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss ${DRONE_TAG} @@ -1978,7 +1978,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1988,7 +1988,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --sign ${DRONE_TAG} @@ -2006,14 +2006,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -2052,7 +2052,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: grafana-server - commands: - apt-get install -y netcat @@ -2129,7 +2129,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-storybook when: paths: @@ -2189,7 +2189,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: store-npm-packages trigger: event: @@ -2236,13 +2236,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2250,7 +2250,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-frontend trigger: event: @@ -2298,7 +2298,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2306,25 +2306,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend-integration trigger: event: @@ -2391,7 +2391,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2399,13 +2399,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update @@ -2421,7 +2421,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: postgres-integration-tests - commands: - apt-get update @@ -2437,7 +2437,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: mysql-integration-tests trigger: event: @@ -2554,7 +2554,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2570,7 +2570,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -2584,13 +2584,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2600,7 +2600,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2609,14 +2609,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2625,7 +2625,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2634,7 +2634,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -2644,14 +2644,14 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise --sign ${DRONE_TAG} @@ -2670,14 +2670,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -2717,7 +2717,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: grafana-server - commands: - apt-get install -y netcat @@ -2825,7 +2825,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 @@ -2847,7 +2847,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: store-npm-packages - commands: - ./bin/grabpl upload-packages --edition enterprise2 @@ -2899,7 +2899,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2915,7 +2915,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -2931,14 +2931,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2947,7 +2947,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-frontend trigger: event: @@ -2984,7 +2984,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mkdir -p bin @@ -3006,7 +3006,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -3028,7 +3028,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3037,25 +3037,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend-integration trigger: event: @@ -3128,7 +3128,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3144,7 +3144,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3154,7 +3154,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3163,13 +3163,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update @@ -3185,7 +3185,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: postgres-integration-tests - commands: - apt-get update @@ -3201,7 +3201,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -3210,7 +3210,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -3219,7 +3219,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: memcached-integration-tests trigger: event: @@ -3660,7 +3660,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - ./bin/grabpl artifacts npm retrieve --tag v${TAG} @@ -3682,7 +3682,7 @@ steps: NPM_TOKEN: from_secret: npm_token failure: ignore - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: release-npm-packages trigger: event: @@ -3912,7 +3912,7 @@ steps: environment: GCP_KEY: from_secret: gcp_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: artifacts-page trigger: event: @@ -3957,18 +3957,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -3982,7 +3982,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -3991,7 +3991,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -4000,7 +4000,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -4010,7 +4010,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -4028,14 +4028,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -4074,7 +4074,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: grafana-server - commands: - apt-get install -y netcat @@ -4151,7 +4151,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-storybook when: paths: @@ -4230,13 +4230,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4244,7 +4244,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-frontend trigger: ref: @@ -4289,7 +4289,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4297,25 +4297,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend-integration trigger: ref: @@ -4379,7 +4379,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4387,13 +4387,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update @@ -4409,7 +4409,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: postgres-integration-tests - commands: - apt-get update @@ -4425,7 +4425,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: mysql-integration-tests trigger: ref: @@ -4532,7 +4532,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -4547,7 +4547,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -4561,13 +4561,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -4577,7 +4577,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4586,14 +4586,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4602,7 +4602,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4611,7 +4611,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -4621,7 +4621,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} @@ -4629,7 +4629,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4649,14 +4649,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -4696,7 +4696,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: grafana-server - commands: - apt-get install -y netcat @@ -4811,7 +4811,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 @@ -4871,7 +4871,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -4886,7 +4886,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -4902,14 +4902,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4918,7 +4918,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-frontend trigger: ref: @@ -4952,7 +4952,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mkdir -p bin @@ -4973,7 +4973,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -4995,7 +4995,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5004,25 +5004,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: test-backend-integration trigger: ref: @@ -5092,7 +5092,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5107,7 +5107,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -5117,7 +5117,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5126,13 +5126,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: wire-install - commands: - apt-get update @@ -5148,7 +5148,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: postgres-integration-tests - commands: - apt-get update @@ -5164,7 +5164,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -5173,7 +5173,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -5182,7 +5182,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.4 + image: grafana/build-container:1.6.5 name: memcached-integration-tests trigger: ref: @@ -5514,6 +5514,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: d703e0a1b27d8396587f430f4175ec924dd51baf4e5b89ff49c94560b9452631 +hmac: 1d42ccac383b4cacb1a626ffdc71847208cca3b464a5ba80e012703b47d2b347 ... diff --git a/Dockerfile b/Dockerfile index 1032ba60ae7..c79dadd34f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16-alpine3.15 as js-builder +FROM node:18-alpine3.15 as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 077d97a0c99..a7835b5513a 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -1,4 +1,4 @@ -FROM node:16-alpine3.15 as js-builder +FROM node:18-alpine3.15 as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 202d9e192b2..fb20192b939 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -18,7 +18,7 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen ``` brew install git brew install go -brew install node@16 +brew install node@18 npm install -g yarn ``` diff --git a/package.json b/package.json index f9b734b7e86..d4f1cbff496 100644 --- a/package.json +++ b/package.json @@ -431,7 +431,7 @@ ] }, "engines": { - "node": ">= 16" + "node": ">= 18" }, "packageManager": "yarn@3.2.4" } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index fa0eb33020e..454e52b6f29 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -71,6 +71,7 @@ beforeEach(() => { config.featureToggles.publicDashboards = true; mockDashboard = new DashboardModel({ uid: 'mockDashboardUid', + timezone: 'utc', }); mockPanel = new PanelModel({ @@ -145,7 +146,7 @@ describe('SharePublic', () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); await screen.findByText('Welcome to Grafana public dashboards alpha!'); - expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 01:59:59')).toBeInTheDocument(); + expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 00:59:59')).toBeInTheDocument(); }); it('when modal is opened, then loader spinner appears and inputs are disabled', async () => { mockDashboard.meta.hasPublicDashboard = true; diff --git a/scripts/build/ci-build/Dockerfile b/scripts/build/ci-build/Dockerfile index 89b48b10d48..c78c1a3249f 100644 --- a/scripts/build/ci-build/Dockerfile +++ b/scripts/build/ci-build/Dockerfile @@ -105,7 +105,7 @@ FROM debian:buster-20220822 ENV GOVERSION=1.19.3 \ PATH=/usr/local/go/bin:$PATH \ GOPATH=/go \ - NODEVERSION=16.14.0-1nodesource1 \ + NODEVERSION=18.12.0-1nodesource1 \ YARNVERSION=1.22.19-1 # Use ARG so as not to persist environment variable in image @@ -141,7 +141,7 @@ RUN apt-get update && \ gem install --conservative -N fpm && \ ln -s /usr/bin/llvm-dsymutil-6.0 /usr/bin/dsymutil && \ curl -fsS https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - && \ - curl -O https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_${NODEVERSION}_amd64.deb &&\ + curl -O https://deb.nodesource.com/node_18.x/pool/main/n/nodejs/nodejs_${NODEVERSION}_amd64.deb &&\ dpkg -i nodejs_${NODEVERSION}_amd64.deb &&\ rm nodejs_${NODEVERSION}_amd64.deb &&\ curl -fsS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ diff --git a/scripts/build/ci-build/README.md b/scripts/build/ci-build/README.md index 00d396d4b36..685324a24a7 100644 --- a/scripts/build/ci-build/README.md +++ b/scripts/build/ci-build/README.md @@ -14,3 +14,5 @@ In order to build and publish the Grafana build Docker image, execute the follow docker build -t grafana/build-container: . docker push grafana/build-container: ``` + +If you're running on a machine that has an ARM chip (Apple M1/M2, etc.), add `--platform linux/amd64` to the `docker build` command. It can take approximately four hours for an initial build to complete. Due to caching, subsequent builds take less time. diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index a21f288106b..0e430dfbc67 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,7 +1,7 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') grabpl_version = 'v3.0.16' -build_image = 'grafana/build-container:1.6.4' +build_image = 'grafana/build-container:1.6.5' publish_image = 'grafana/grafana-ci-deploy:1.3.3' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' alpine_image = 'alpine:3.15.6' From 6625f6f0c8637207c7ff9ea969086ff524043f54 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 11 Nov 2022 12:21:38 +0000 Subject: [PATCH 200/926] Chore: Update .nvmrc (#58641) Update .nvmrc --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index bf79505bb85..9dfef472196 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.14.0 +v18.12.0 From 88a829e10338acfd3d20dffd14b766c995678089 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 11 Nov 2022 14:26:43 +0100 Subject: [PATCH 201/926] Fix: don't show an error when receiver status is not available (#58638) --- .../alerting/unified/api/receiversApi.ts | 30 +++++++++++-------- .../components/receivers/ReceiversTable.tsx | 6 ++-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/public/app/features/alerting/unified/api/receiversApi.ts b/public/app/features/alerting/unified/api/receiversApi.ts index 3dc45ba8215..1b9166ae96c 100644 --- a/public/app/features/alerting/unified/api/receiversApi.ts +++ b/public/app/features/alerting/unified/api/receiversApi.ts @@ -1,19 +1,20 @@ -import { ContactPointsState, ReceiversStateDTO } from 'app/types'; +import { ContactPointsState } from 'app/types'; import { CONTACT_POINTS_STATE_INTERVAL_MS } from '../utils/constants'; -import { getDatasourceAPIUid } from '../utils/datasource'; import { alertingApi } from './alertingApi'; -import { contactPointsStateDtoToModel } from './grafana'; +import { fetchContactPointsState } from './grafana'; export const receiversApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ - contactPointsState: build.query({ - query: (amSourceName) => ({ - url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/config/api/v1/receivers`, - }), - transformResponse: (receivers: ReceiversStateDTO[]) => { - return contactPointsStateDtoToModel(receivers); + contactPointsState: build.query({ + queryFn: async ({ amSourceName }) => { + try { + const contactPointsState = await fetchContactPointsState(amSourceName); + return { data: contactPointsState }; + } catch (error) { + return { error: error }; + } }, }), }), @@ -21,9 +22,12 @@ export const receiversApi = alertingApi.injectEndpoints({ export const useGetContactPointsState = (alertManagerSourceName: string) => { const contactPointsStateEmpty: ContactPointsState = { receivers: {}, errorCount: 0 }; - const { currentData: contactPointsState } = receiversApi.useContactPointsStateQuery(alertManagerSourceName ?? '', { - skip: !alertManagerSourceName, - pollingInterval: CONTACT_POINTS_STATE_INTERVAL_MS, - }); + const { currentData: contactPointsState } = receiversApi.useContactPointsStateQuery( + { amSourceName: alertManagerSourceName ?? '' }, + { + skip: !alertManagerSourceName, + pollingInterval: CONTACT_POINTS_STATE_INTERVAL_MS, + } + ); return contactPointsState ?? contactPointsStateEmpty; }; diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index 42f9b455210..d274fe7c4b5 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -117,13 +117,13 @@ function ReceiverHealth({ errorsByReceiver, someWithNoAttempt }: ReceiverHealthP ); } + const useContactPointsState = (alertManagerName: string) => { - const contactPointsState = useGetContactPointsState(alertManagerName ?? ''); + const contactPointsState = useGetContactPointsState(alertManagerName); const receivers: ReceiversState = contactPointsState?.receivers ?? {}; - const errorStateAvailable = Object.keys(receivers).length > 0; // this logic can change depending on how we implement this in the BE + const errorStateAvailable = Object.keys(receivers).length > 0; return { contactPointsState, errorStateAvailable }; }; - interface ReceiverItem { name: string; types: string[]; From 080ea88af7b6036dd1fea0201a75e7b6fdff8fb3 Mon Sep 17 00:00:00 2001 From: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Fri, 11 Nov 2022 14:28:24 +0100 Subject: [PATCH 202/926] =?UTF-8?q?Nested=20Folders:=20Support=20getting?= =?UTF-8?q?=20of=20nested=20folder=20in=20folder=20service=20wh=E2=80=A6?= =?UTF-8?q?=20(#58597)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Nested Folders: Support getting of nested folder in folder service when feature flag is set * Fix lint * Fix some tests * Fix ngalert test * ngalert fix * Fix API tests * Fix some tests and lint * Fix lint 2 * Fix library elements and panels * Add access control to get folder * Cleanup and minor test change --- pkg/api/dashboard.go | 8 +- pkg/api/dashboard_test.go | 9 +- pkg/api/folder.go | 51 ++--------- pkg/api/folder_permission.go | 21 +++-- pkg/api/folder_test.go | 45 ---------- .../dashboardimport/service/service.go | 11 ++- pkg/services/dashboards/accesscontrol.go | 6 +- pkg/services/dashboards/accesscontrol_test.go | 31 ++++--- pkg/services/dashboards/dashboard.go | 7 +- pkg/services/dashboards/database/database.go | 13 +-- .../database/database_folder_test.go | 6 +- .../dashboards/service/dashboard_service.go | 2 +- .../service/dashboard_service_test.go | 3 +- pkg/services/dashboards/store_mock.go | 25 +++--- pkg/services/folder/folderimpl/folder.go | 73 +++++++++++----- pkg/services/folder/folderimpl/folder_test.go | 86 ++++++++----------- pkg/services/folder/foldertest/foldertest.go | 24 +++--- pkg/services/folder/service.go | 19 ++-- pkg/services/libraryelements/api.go | 21 ++--- pkg/services/libraryelements/guard.go | 9 +- .../libraryelements_create_test.go | 10 +-- .../libraryelements_delete_test.go | 2 +- .../libraryelements_get_all_test.go | 66 +++++++------- .../libraryelements_get_test.go | 6 +- .../libraryelements_patch_test.go | 10 +-- .../libraryelements/libraryelements_test.go | 53 ++++++------ .../librarypanels/librarypanels_test.go | 6 +- pkg/services/ngalert/api/api_prometheus.go | 3 +- pkg/services/ngalert/api/api_ruler.go | 14 +-- pkg/services/ngalert/api/api_ruler_test.go | 19 ++-- .../ngalert/api/api_ruler_validation.go | 8 +- .../ngalert/api/api_ruler_validation_test.go | 28 +++--- pkg/services/ngalert/api/persist.go | 6 +- pkg/services/ngalert/models/testing.go | 6 +- pkg/services/ngalert/ngalert_test.go | 12 +-- pkg/services/ngalert/store/alert_rule.go | 21 ++--- pkg/services/ngalert/store/alert_rule_test.go | 6 +- pkg/services/ngalert/tests/fakes/rules.go | 28 +++--- pkg/services/ngalert/tests/util.go | 18 ++-- 39 files changed, 372 insertions(+), 420 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 926af720944..13e9b05968e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" @@ -394,14 +395,17 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa cmd.OrgId = c.OrgID cmd.UserId = c.UserID if cmd.FolderUid != "" { - folder, err := hs.folderService.GetFolderByUID(ctx, c.SignedInUser, c.OrgID, cmd.FolderUid) + folder, err := hs.folderService.Get(ctx, &folder.GetFolderQuery{ + OrgID: c.OrgID, + UID: &cmd.FolderUid, + }) if err != nil { if errors.Is(err, dashboards.ErrFolderNotFound) { return response.Error(400, "Folder not found", err) } return response.Error(500, "Error while checking folder ID", err) } - cmd.FolderId = folder.Id + cmd.FolderId = folder.ID } dash := cmd.GetDashboardModel() diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index bd4a7f08b3d..3723ccd81a4 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -32,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardversion/dashvertest" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/live" @@ -642,8 +643,8 @@ func TestDashboardAPIEndpoint(t *testing.T) { dashboardService.On("SaveDashboard", mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool")). Return(&models.Dashboard{Id: dashID, Uid: "uid", Title: "Dash", Slug: "dash", Version: 2}, nil) - mockFolder := &fakeFolderService{ - GetFolderByUIDResult: &models.Folder{Id: 1, Uid: "folderUID", Title: "Folder"}, + mockFolder := &foldertest.FakeService{ + ExpectedFolder: &folder.Folder{ID: 1, UID: "folderUID", Title: "Folder"}, } postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, dashboardService, mockFolder, func(sc *scenarioContext) { @@ -673,8 +674,8 @@ func TestDashboardAPIEndpoint(t *testing.T) { dashboardService := dashboards.NewFakeDashboardService(t) - mockFolder := &fakeFolderService{ - GetFolderByUIDError: errors.New("Error while searching Folder ID"), + mockFolder := &foldertest.FakeService{ + ExpectedError: errors.New("Error while searching Folder ID"), } postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, dashboardService, mockFolder, func(sc *scenarioContext) { diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 4d4197e2d43..280fbb5a804 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -67,13 +67,14 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { - folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgID, web.Params(c.Req)[":uid"]) + uid := web.Params(c.Req)[":uid"] + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid}) if err != nil { return apierrors.ToFolderErrorResponse(err) } - g := guardian.New(c.Req.Context(), folder.Id, c.OrgID, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder)) + g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser) + return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, folder)) } // swagger:route GET /folders/id/{folder_id} folders getFolderByID @@ -93,13 +94,13 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } - folder, err := hs.folderService.GetFolderByID(c.Req.Context(), c.SignedInUser, id, c.OrgID) + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{ID: &id, OrgID: c.OrgID}) if err != nil { return apierrors.ToFolderErrorResponse(err) } - g := guardian.New(c.Req.Context(), folder.Id, c.OrgID, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder)) + g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser) + return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, folder)) } // swagger:route POST /folders folders createFolder @@ -182,8 +183,8 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { if err != nil { return apierrors.ToFolderErrorResponse(err) } - g := guardian.New(c.Req.Context(), result.Id, c.OrgID, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c, g, result)) + g := guardian.New(c.Req.Context(), result.ID, c.OrgID, c.SignedInUser) + return response.JSON(http.StatusOK, hs.newToFolderDto(c, g, result)) } // swagger:route DELETE /folders/{folder_uid} folders deleteFolder @@ -218,40 +219,6 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // return response.JSON(http.StatusOK, "") } -func (hs *HTTPServer) toFolderDto(c *models.ReqContext, g guardian.DashboardGuardian, folder *models.Folder) dtos.Folder { - canEdit, _ := g.CanEdit() - canSave, _ := g.CanSave() - canAdmin, _ := g.CanAdmin() - canDelete, _ := g.CanDelete() - - // Finding creator and last updater of the folder - updater, creator := anonString, anonString - if folder.CreatedBy > 0 { - creator = hs.getUserLogin(c.Req.Context(), folder.CreatedBy) - } - if folder.UpdatedBy > 0 { - updater = hs.getUserLogin(c.Req.Context(), folder.UpdatedBy) - } - - return dtos.Folder{ - Id: folder.Id, - Uid: folder.Uid, - Title: folder.Title, - Url: folder.Url, - HasACL: folder.HasACL, - CanSave: canSave, - CanEdit: canEdit, - CanAdmin: canAdmin, - CanDelete: canDelete, - CreatedBy: creator, - Created: folder.Created, - UpdatedBy: updater, - Updated: folder.Updated, - Version: folder.Version, - AccessControl: hs.getAccessControlMetadata(c, c.OrgID, dashboards.ScopeFoldersPrefix, folder.Uid), - } -} - func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardGuardian, folder *folder.Folder) dtos.Folder { canEdit, _ := g.CanEdit() canSave, _ := g.CanSave() diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index 4ae2c81439f..555d1a67c8e 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -26,13 +27,14 @@ import ( // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Response { - folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgID, web.Params(c.Req)[":uid"]) + uid := web.Params(c.Req)[":uid"] + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid}) if err != nil { return apierrors.ToFolderErrorResponse(err) } - g := guardian.New(c.Req.Context(), folder.Id, c.OrgID, c.SignedInUser) + g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser) if canAdmin, err := g.CanAdmin(); err != nil || !canAdmin { return apierrors.ToFolderErrorResponse(dashboards.ErrFolderAccessDenied) @@ -49,7 +51,7 @@ func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Res continue } - perm.FolderId = folder.Id + perm.FolderId = folder.ID perm.DashboardId = 0 perm.UserAvatarUrl = dtos.GetGravatarUrl(perm.UserEmail) @@ -87,12 +89,13 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return response.Error(400, err.Error(), err) } - folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgID, web.Params(c.Req)[":uid"]) + uid := web.Params(c.Req)[":uid"] + folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: &uid}) if err != nil { return apierrors.ToFolderErrorResponse(err) } - g := guardian.New(c.Req.Context(), folder.Id, c.OrgID, c.SignedInUser) + g := guardian.New(c.Req.Context(), folder.ID, c.OrgID, c.SignedInUser) canAdmin, err := g.CanAdmin() if err != nil { return apierrors.ToFolderErrorResponse(err) @@ -106,7 +109,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res for _, item := range apiCmd.Items { items = append(items, &models.DashboardACL{ OrgID: c.OrgID, - DashboardID: folder.Id, + DashboardID: folder.ID, UserID: item.UserID, TeamID: item.TeamID, Role: item.Role, @@ -140,13 +143,13 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res if err != nil { return response.Error(500, "Error while checking dashboard permissions", err) } - if err := hs.updateDashboardAccessControl(c.Req.Context(), c.OrgID, folder.Uid, true, items, old); err != nil { + if err := hs.updateDashboardAccessControl(c.Req.Context(), c.OrgID, folder.UID, true, items, old); err != nil { return response.Error(500, "Failed to create permission", err) } return response.Success("Dashboard permissions updated") } - if err := hs.DashboardService.UpdateDashboardACL(c.Req.Context(), folder.Id, items); err != nil { + if err := hs.DashboardService.UpdateDashboardACL(c.Req.Context(), folder.ID, items); err != nil { if errors.Is(err, models.ErrDashboardACLInfoMissing) { err = models.ErrFolderACLInfoMissing } @@ -163,7 +166,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return response.JSON(http.StatusOK, util.DynMap{ "message": "Folder permissions updated", - "id": folder.Id, + "id": folder.ID, "title": folder.Title, }) } diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index d2f15ba5bb9..71c4136bae2 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "fmt" "net/http" @@ -294,47 +293,3 @@ func updateFolderScenario(t *testing.T, desc string, url string, routePattern st fn(sc) }) } - -type fakeFolderService struct { - folder.Service - - GetFoldersResult []*models.Folder - GetFoldersError error - GetFolderByUIDResult *models.Folder - GetFolderByUIDError error - GetFolderByIDResult *models.Folder - GetFolderByIDError error - CreateFolderResult *models.Folder - CreateFolderError error - UpdateFolderResult *models.Folder - UpdateFolderError error - DeleteFolderResult *folder.Folder - DeleteFolderError error - DeletedFolderUids []string -} - -func (s *fakeFolderService) GetFolders(ctx context.Context, user *user.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) { - return s.GetFoldersResult, s.GetFoldersError -} - -func (s *fakeFolderService) GetFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*models.Folder, error) { - return s.GetFolderByIDResult, s.GetFolderByIDError -} - -func (s *fakeFolderService) GetFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*models.Folder, error) { - return s.GetFolderByUIDResult, s.GetFolderByUIDError -} - -func (s *fakeFolderService) CreateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) { - return s.CreateFolderResult, s.CreateFolderError -} - -func (s *fakeFolderService) UpdateFolder(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error { - cmd.Result = s.UpdateFolderResult - return s.UpdateFolderError -} - -func (s *fakeFolderService) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderCommand) error { - s.DeletedFolderUids = append(s.DeletedFolderUids, cmd.UID) - return s.DeleteFolderError -} diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index f4da623cc75..0b345d21886 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -85,17 +85,20 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb // here we need to get FolderId from FolderUID if it present in the request, if both exist, FolderUID would overwrite FolderID if req.FolderUid != "" { - folder, err := s.folderService.GetFolderByUID(ctx, req.User, req.User.OrgID, req.FolderUid) + folder, err := s.folderService.Get(ctx, &folder.GetFolderQuery{ + OrgID: req.User.OrgID, + UID: &req.FolderUid, + }) if err != nil { return nil, err } - req.FolderId = folder.Id + req.FolderId = folder.ID } else { - folder, err := s.folderService.GetFolderByID(ctx, req.User, req.FolderId, req.User.OrgID) + folder, err := s.folderService.Get(ctx, &folder.GetFolderQuery{ID: &req.FolderId, OrgID: req.User.OrgID}) if err != nil { return nil, err } - req.FolderUid = folder.Uid + req.FolderUid = folder.UID } saveCmd := models.SaveDashboardCommand{ diff --git a/pkg/services/dashboards/accesscontrol.go b/pkg/services/dashboards/accesscontrol.go index 3537574f630..49df72b9b45 100644 --- a/pkg/services/dashboards/accesscontrol.go +++ b/pkg/services/dashboards/accesscontrol.go @@ -53,7 +53,7 @@ func NewFolderNameScopeResolver(db Store) (string, ac.ScopeAttributeResolver) { if err != nil { return nil, err } - return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.Uid)}, nil + return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.UID)}, nil }) } @@ -79,7 +79,7 @@ func NewFolderIDScopeResolver(db Store) (string, ac.ScopeAttributeResolver) { return nil, err } - return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.Uid)}, nil + return []string{ScopeFoldersProvider.GetResourceScopeUID(folder.UID)}, nil }) } @@ -142,7 +142,7 @@ func resolveDashboardScope(ctx context.Context, db Store, orgID int64, dashboard if err != nil { return nil, err } - folderUID = folder.Uid + folderUID = folder.UID } return []string{ diff --git a/pkg/services/dashboards/accesscontrol_test.go b/pkg/services/dashboards/accesscontrol_test.go index d9e57e44657..d59f739aec0 100644 --- a/pkg/services/dashboards/accesscontrol_test.go +++ b/pkg/services/dashboards/accesscontrol_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/util" ) @@ -29,9 +30,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { orgId := rand.Int63() title := "Very complex :title with: and /" + util.GenerateShortUID() - db := models.NewFolder(title) - db.Id = rand.Int63() - db.Uid = util.GenerateShortUID() + db := &folder.Folder{Title: title, ID: rand.Int63(), UID: util.GenerateShortUID()} dashboardStore.On("GetFolderByTitle", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() scope := "folders:name:" + title @@ -40,7 +39,7 @@ func TestNewFolderNameScopeResolver(t *testing.T) { require.NoError(t, err) require.Len(t, resolvedScopes, 1) - require.Equal(t, fmt.Sprintf("folders:uid:%v", db.Uid), resolvedScopes[0]) + require.Equal(t, fmt.Sprintf("folders:uid:%v", db.UID), resolvedScopes[0]) dashboardStore.AssertCalled(t, "GetFolderByTitle", mock.Anything, orgId, title) }) @@ -88,17 +87,17 @@ func TestNewFolderIDScopeResolver(t *testing.T) { orgId := rand.Int63() uid := util.GenerateShortUID() - db := &models.Folder{Id: rand.Int63(), Uid: uid} + db := &folder.Folder{ID: rand.Int63(), UID: uid} dashboardStore.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(db, nil).Once() - scope := "folders:id:" + strconv.FormatInt(db.Id, 10) + scope := "folders:id:" + strconv.FormatInt(db.ID, 10) resolvedScopes, err := resolver.Resolve(context.Background(), orgId, scope) require.NoError(t, err) require.Len(t, resolvedScopes, 1) - require.Equal(t, fmt.Sprintf("folders:uid:%v", db.Uid), resolvedScopes[0]) + require.Equal(t, fmt.Sprintf("folders:uid:%v", db.UID), resolvedScopes[0]) - dashboardStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.Id) + dashboardStore.AssertCalled(t, "GetFolderByID", mock.Anything, orgId, db.ID) }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { dashboardStore := &FakeDashboardStore{} @@ -157,18 +156,18 @@ func TestNewDashboardIDScopeResolver(t *testing.T) { _, resolver := NewDashboardIDScopeResolver(store) orgID := rand.Int63() - folder := &models.Folder{Id: 2, Uid: "2"} - dashboard := &models.Dashboard{Id: 1, FolderId: folder.Id, Uid: "1"} + folder := &folder.Folder{ID: 2, UID: "2"} + dashboard := &models.Dashboard{Id: 1, FolderId: folder.ID, Uid: "1"} store.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() - store.On("GetFolderByID", mock.Anything, orgID, folder.Id).Return(folder, nil).Once() + store.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() scope := ac.Scope("dashboards", "id", strconv.FormatInt(dashboard.Id, 10)) resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) require.NoError(t, err) require.Len(t, resolvedScopes, 2) require.Equal(t, fmt.Sprintf("dashboards:uid:%s", dashboard.Uid), resolvedScopes[0]) - require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.Uid), resolvedScopes[1]) + require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.UID), resolvedScopes[1]) }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { @@ -203,18 +202,18 @@ func TestNewDashboardUIDScopeResolver(t *testing.T) { _, resolver := NewDashboardUIDScopeResolver(store) orgID := rand.Int63() - folder := &models.Folder{Id: 2, Uid: "2"} - dashboard := &models.Dashboard{Id: 1, FolderId: folder.Id, Uid: "1"} + folder := &folder.Folder{ID: 2, UID: "2"} + dashboard := &models.Dashboard{Id: 1, FolderId: folder.ID, Uid: "1"} store.On("GetDashboard", mock.Anything, mock.Anything).Return(dashboard, nil).Once() - store.On("GetFolderByID", mock.Anything, orgID, folder.Id).Return(folder, nil).Once() + store.On("GetFolderByID", mock.Anything, orgID, folder.ID).Return(folder, nil).Once() scope := ac.Scope("dashboards", "uid", dashboard.Uid) resolvedScopes, err := resolver.Resolve(context.Background(), orgID, scope) require.NoError(t, err) require.Len(t, resolvedScopes, 2) require.Equal(t, fmt.Sprintf("dashboards:uid:%s", dashboard.Uid), resolvedScopes[0]) - require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.Uid), resolvedScopes[1]) + require.Equal(t, fmt.Sprintf("folders:uid:%s", folder.UID), resolvedScopes[1]) }) t.Run("resolver should fail if input scope is not expected", func(t *testing.T) { diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 82f4eaa0850..c8f80d0b7a1 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" ) // DashboardService is a service for operating on dashboards. @@ -89,9 +90,9 @@ type Store interface { //go:generate mockery --name FolderStore --structname FakeFolderStore --inpackage --filename folder_store_mock.go type FolderStore interface { // GetFolderByTitle retrieves a folder by its title - GetFolderByTitle(ctx context.Context, orgID int64, title string) (*models.Folder, error) + GetFolderByTitle(ctx context.Context, orgID int64, title string) (*folder.Folder, error) // GetFolderByUID retrieves a folder by its UID - GetFolderByUID(ctx context.Context, orgID int64, uid string) (*models.Folder, error) + GetFolderByUID(ctx context.Context, orgID int64, uid string) (*folder.Folder, error) // GetFolderByID retrieves a folder by its ID - GetFolderByID(ctx context.Context, orgID int64, id int64) (*models.Folder, error) + GetFolderByID(ctx context.Context, orgID int64, id int64) (*folder.Folder, error) } diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 045d2597239..640274db692 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -74,7 +75,7 @@ func (d *DashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dashbo return isParentFolderChanged, nil } -func (d *DashboardStore) GetFolderByTitle(ctx context.Context, orgID int64, title string) (*models.Folder, error) { +func (d *DashboardStore) GetFolderByTitle(ctx context.Context, orgID int64, title string) (*folder.Folder, error) { if title == "" { return nil, dashboards.ErrFolderTitleEmpty } @@ -94,10 +95,10 @@ func (d *DashboardStore) GetFolderByTitle(ctx context.Context, orgID int64, titl dashboard.SetUid(dashboard.Uid) return nil }) - return models.DashboardToFolder(&dashboard), err + return folder.FromDashboard(&dashboard), err } -func (d *DashboardStore) GetFolderByID(ctx context.Context, orgID int64, id int64) (*models.Folder, error) { +func (d *DashboardStore) GetFolderByID(ctx context.Context, orgID int64, id int64) (*folder.Folder, error) { dashboard := models.Dashboard{OrgId: orgID, FolderId: 0, Id: id} err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { has, err := sess.Table(&models.Dashboard{}).Where("is_folder = " + d.store.GetDialect().BooleanStr(true)).Where("folder_id=0").Get(&dashboard) @@ -114,10 +115,10 @@ func (d *DashboardStore) GetFolderByID(ctx context.Context, orgID int64, id int6 if err != nil { return nil, err } - return models.DashboardToFolder(&dashboard), nil + return folder.FromDashboard(&dashboard), nil } -func (d *DashboardStore) GetFolderByUID(ctx context.Context, orgID int64, uid string) (*models.Folder, error) { +func (d *DashboardStore) GetFolderByUID(ctx context.Context, orgID int64, uid string) (*folder.Folder, error) { if uid == "" { return nil, dashboards.ErrDashboardIdentifierNotSet } @@ -138,7 +139,7 @@ func (d *DashboardStore) GetFolderByUID(ctx context.Context, orgID int64, uid st if err != nil { return nil, err } - return models.DashboardToFolder(&dashboard), nil + return folder.FromDashboard(&dashboard), nil } func (d *DashboardStore) GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*models.DashboardProvisioning, error) { diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 8f104a670ff..b79508f1cd4 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -481,7 +481,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByTitle should find the folder", func(t *testing.T) { result, err := dashboardStore.GetFolderByTitle(context.Background(), orgId, title) require.NoError(t, err) - require.Equal(t, folder1.Id, result.Id) + require.Equal(t, folder1.Id, result.ID) }) }) @@ -494,7 +494,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("should return folder by UID", func(t *testing.T) { d, err := dashboardStore.GetFolderByUID(context.Background(), orgId, folder.Uid) - require.Equal(t, folder.Id, d.Id) + require.Equal(t, folder.Id, d.ID) require.NoError(t, err) }) t.Run("should not find dashboard", func(t *testing.T) { @@ -518,7 +518,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("should return folder by ID", func(t *testing.T) { d, err := dashboardStore.GetFolderByID(context.Background(), orgId, folder.Id) - require.Equal(t, folder.Id, d.Id) + require.Equal(t, folder.Id, d.ID) require.NoError(t, err) }) t.Run("should not find dashboard", func(t *testing.T) { diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 671dc054888..881f2bc09ab 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -600,5 +600,5 @@ func (dr DashboardServiceImpl) CountDashboardsInFolder(ctx context.Context, quer return 0, err } - return dr.dashboardStore.CountDashboardsInFolder(ctx, &dashboards.CountDashboardsInFolderRequest{FolderID: folder.Id, OrgID: u.OrgID}) + return dr.dashboardStore.CountDashboardsInFolder(ctx, &dashboards.CountDashboardsInFolderRequest{FolderID: folder.ID, OrgID: u.OrgID}) } diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index fac1f73b8fa..e2ae5c80bcd 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -226,7 +227,7 @@ func TestDashboardService(t *testing.T) { }) t.Run("Count dashboards in folder", func(t *testing.T) { - fakeStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&models.Folder{}, nil) + fakeStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&folder.Folder{}, nil) fakeStore.On("CountDashboardsInFolder", mock.Anything, mock.AnythingOfType("*dashboards.CountDashboardsInFolderRequest")).Return(int64(3), nil) // set up a ctx with signed in user diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 5824d5332db..309152a23ed 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -5,6 +5,7 @@ package dashboards import ( context "context" + folder "github.com/grafana/grafana/pkg/services/folder" models "github.com/grafana/grafana/pkg/models" mock "github.com/stretchr/testify/mock" ) @@ -194,15 +195,15 @@ func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query } // GetFolderByID provides a mock function with given fields: ctx, orgID, id -func (_m *FakeDashboardStore) GetFolderByID(ctx context.Context, orgID int64, id int64) (*models.Folder, error) { +func (_m *FakeDashboardStore) GetFolderByID(ctx context.Context, orgID int64, id int64) (*folder.Folder, error) { ret := _m.Called(ctx, orgID, id) - var r0 *models.Folder - if rf, ok := ret.Get(0).(func(context.Context, int64, int64) *models.Folder); ok { + var r0 *folder.Folder + if rf, ok := ret.Get(0).(func(context.Context, int64, int64) *folder.Folder); ok { r0 = rf(ctx, orgID, id) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Folder) + r0 = ret.Get(0).(*folder.Folder) } } @@ -217,15 +218,15 @@ func (_m *FakeDashboardStore) GetFolderByID(ctx context.Context, orgID int64, id } // GetFolderByTitle provides a mock function with given fields: ctx, orgID, title -func (_m *FakeDashboardStore) GetFolderByTitle(ctx context.Context, orgID int64, title string) (*models.Folder, error) { +func (_m *FakeDashboardStore) GetFolderByTitle(ctx context.Context, orgID int64, title string) (*folder.Folder, error) { ret := _m.Called(ctx, orgID, title) - var r0 *models.Folder - if rf, ok := ret.Get(0).(func(context.Context, int64, string) *models.Folder); ok { + var r0 *folder.Folder + if rf, ok := ret.Get(0).(func(context.Context, int64, string) *folder.Folder); ok { r0 = rf(ctx, orgID, title) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Folder) + r0 = ret.Get(0).(*folder.Folder) } } @@ -240,15 +241,15 @@ func (_m *FakeDashboardStore) GetFolderByTitle(ctx context.Context, orgID int64, } // GetFolderByUID provides a mock function with given fields: ctx, orgID, uid -func (_m *FakeDashboardStore) GetFolderByUID(ctx context.Context, orgID int64, uid string) (*models.Folder, error) { +func (_m *FakeDashboardStore) GetFolderByUID(ctx context.Context, orgID int64, uid string) (*folder.Folder, error) { ret := _m.Called(ctx, orgID, uid) - var r0 *models.Folder - if rf, ok := ret.Get(0).(func(context.Context, int64, string) *models.Folder); ok { + var r0 *folder.Folder + if rf, ok := ret.Get(0).(func(context.Context, int64, string) *folder.Folder); ok { r0 = rf(ctx, orgID, uid) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Folder) + r0 = ret.Get(0).(*folder.Folder) } } diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 0de6fcf031e..6f3d1818b36 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -34,6 +34,7 @@ type Service struct { searchService *search.SearchService features *featuremgmt.FeatureManager permissions accesscontrol.FolderPermissionsService + accessControl accesscontrol.AccessControl // bus is currently used to publish events that cause scheduler to update rules. bus bus.Bus @@ -62,10 +63,41 @@ func ProvideService( searchService: searchService, features: features, permissions: folderPermissionsService, + accessControl: ac, bus: bus, } } +func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + + if s.cfg.IsFeatureToggleEnabled(featuremgmt.FlagNestedFolders) { + if ok, err := s.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission( + dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(*cmd.UID), + )); !ok { + if err != nil { + return nil, toFolderError(err) + } + return nil, dashboards.ErrFolderAccessDenied + } + return s.store.Get(ctx, *cmd) + } + + switch { + case cmd.UID != nil: + return s.getFolderByUID(ctx, user, cmd.OrgID, *cmd.UID) + case cmd.ID != nil: + return s.getFolderByID(ctx, user, *cmd.ID, cmd.OrgID) + case cmd.Title != nil: + return s.getFolderByTitle(ctx, user, cmd.OrgID, *cmd.Title) + default: + return nil, folder.ErrBadRequest.Errorf("either on of UID, ID, Title fields must be present") + } +} + func (s *Service) GetFolders(ctx context.Context, user *user.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) { searchQuery := search.Query{ SignedInUser: user, @@ -95,9 +127,9 @@ func (s *Service) GetFolders(ctx context.Context, user *user.SignedInUser, orgID return folders, nil } -func (s *Service) GetFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*models.Folder, error) { +func (s *Service) getFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*folder.Folder, error) { if id == 0 { - return &models.Folder{Id: id, Title: "General"}, nil + return &folder.Folder{ID: id, Title: "General"}, nil } dashFolder, err := s.dashboardStore.GetFolderByID(ctx, orgID, id) @@ -105,7 +137,7 @@ func (s *Service) GetFolderByID(ctx context.Context, user *user.SignedInUser, id return nil, err } - g := guardian.New(ctx, dashFolder.Id, orgID, user) + g := guardian.New(ctx, dashFolder.ID, orgID, user) if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) @@ -116,13 +148,13 @@ func (s *Service) GetFolderByID(ctx context.Context, user *user.SignedInUser, id return dashFolder, nil } -func (s *Service) GetFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*models.Folder, error) { +func (s *Service) getFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*folder.Folder, error) { dashFolder, err := s.dashboardStore.GetFolderByUID(ctx, orgID, uid) if err != nil { return nil, err } - g := guardian.New(ctx, dashFolder.Id, orgID, user) + g := guardian.New(ctx, dashFolder.ID, orgID, user) if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) @@ -133,13 +165,13 @@ func (s *Service) GetFolderByUID(ctx context.Context, user *user.SignedInUser, o return dashFolder, nil } -func (s *Service) GetFolderByTitle(ctx context.Context, user *user.SignedInUser, orgID int64, title string) (*models.Folder, error) { +func (s *Service) getFolderByTitle(ctx context.Context, user *user.SignedInUser, orgID int64, title string) (*folder.Folder, error) { dashFolder, err := s.dashboardStore.GetFolderByTitle(ctx, orgID, title) if err != nil { return nil, err } - g := guardian.New(ctx, dashFolder.Id, orgID, user) + g := guardian.New(ctx, dashFolder.ID, orgID, user) if canView, err := g.CanView(); err != nil || !canView { if err != nil { return nil, toFolderError(err) @@ -189,7 +221,7 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( return nil, toFolderError(err) } - var createdFolder *models.Folder + var createdFolder *folder.Folder createdFolder, err = s.dashboardStore.GetFolderByID(ctx, cmd.OrgID, dash.Id) if err != nil { return nil, err @@ -209,9 +241,9 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( {BuiltinRole: string(org.RoleViewer), Permission: models.PERMISSION_VIEW.String()}, }...) - _, permissionErr = s.permissions.SetPermissions(ctx, cmd.OrgID, createdFolder.Uid, permissions...) + _, permissionErr = s.permissions.SetPermissions(ctx, cmd.OrgID, createdFolder.UID, permissions...) } else if s.cfg.EditorsCanAdmin && user.IsRealUser() && !user.IsAnonymous { - permissionErr = s.MakeUserAdmin(ctx, cmd.OrgID, userID, createdFolder.Id, true) + permissionErr = s.MakeUserAdmin(ctx, cmd.OrgID, userID, createdFolder.ID, true) } if permissionErr != nil { @@ -242,7 +274,7 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( // We'll log the error and also roll back the previously-created // (legacy) folder. s.log.Error("error saving folder to nested folder store", err) - err = s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.Uid, OrgID: cmd.OrgID, ForceDeleteRules: true}) + err = s.DeleteFolder(ctx, &folder.DeleteFolderCommand{UID: createdFolder.UID, OrgID: cmd.OrgID, ForceDeleteRules: true}) if err != nil { s.log.Error("error deleting folder after failed save to nested folder store", err) } @@ -254,7 +286,7 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( return folder.FromDashboard(dash), nil } -func (s *Service) Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*models.Folder, error) { +func (s *Service) Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*folder.Folder, error) { foldr, err := s.legacyUpdate(ctx, user, orgID, existingUid, cmd) if err != nil { return nil, err @@ -276,7 +308,7 @@ func (s *Service) Update(ctx context.Context, user *user.SignedInUser, orgID int if err != nil { return nil, err } - _, err = s.store.Update(ctx, folder.UpdateFolderCommand{ + foldr, err := s.store.Update(ctx, folder.UpdateFolderCommand{ Folder: getFolder, NewUID: &cmd.Uid, NewTitle: &cmd.Title, @@ -285,11 +317,12 @@ func (s *Service) Update(ctx context.Context, user *user.SignedInUser, orgID int if err != nil { return nil, err } + return foldr, nil } return foldr, nil } -func (s *Service) legacyUpdate(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*models.Folder, error) { +func (s *Service) legacyUpdate(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*folder.Folder, error) { query := models.GetDashboardQuery{OrgId: orgID, Uid: existingUid} _, err := s.dashboardStore.GetDashboard(ctx, &query) if err != nil { @@ -322,7 +355,7 @@ func (s *Service) legacyUpdate(ctx context.Context, user *user.SignedInUser, org return nil, toFolderError(err) } - var foldr *models.Folder + var foldr *folder.Folder foldr, err = s.dashboardStore.GetFolderByID(ctx, orgID, dash.Id) if err != nil { return nil, err @@ -359,7 +392,7 @@ func (s *Service) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderComm return err } - guard := guardian.New(ctx, dashFolder.Id, cmd.OrgID, user) + guard := guardian.New(ctx, dashFolder.ID, cmd.OrgID, user) if canSave, err := guard.CanDelete(); err != nil || !canSave { if err != nil { return toFolderError(err) @@ -367,7 +400,7 @@ func (s *Service) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderComm return dashboards.ErrFolderAccessDenied } - deleteCmd := models.DeleteDashboardCommand{OrgId: cmd.OrgID, Id: dashFolder.Id, ForceDeleteFolderRules: cmd.ForceDeleteRules} + deleteCmd := models.DeleteDashboardCommand{OrgId: cmd.OrgID, Id: dashFolder.ID, ForceDeleteFolderRules: cmd.ForceDeleteRules} if err := s.dashboardStore.DeleteDashboard(ctx, &deleteCmd); err != nil { return toFolderError(err) @@ -416,12 +449,6 @@ func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) e return nil } -func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { - // check the flag, if old - do whatever did before - // for new only the store - return s.store.Get(ctx, *cmd) -} - func (s *Service) GetParents(ctx context.Context, cmd *folder.GetParentsQuery) ([]*folder.Folder, error) { // check the flag, if old - do whatever did before // for new only the store diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 5b8a142ea90..19ca8e8ddba 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" dashboardsvc "github.com/grafana/grafana/pkg/services/dashboards/service" @@ -79,26 +80,26 @@ func TestIntegrationFolderService(t *testing.T) { folderId := rand.Int63() folderUID := util.GenerateShortUID() - f := models.NewFolder("Folder") - f.Id = folderId - f.Uid = folderUID + f := folder.NewFolder("Folder", "") + f.ID = folderId + f.UID = folderUID dashStore.On("GetFolderByID", mock.Anything, orgID, folderId).Return(f, nil) dashStore.On("GetFolderByUID", mock.Anything, orgID, folderUID).Return(f, nil) t.Run("When get folder by id should return access denied error", func(t *testing.T) { - _, err := service.GetFolderByID(context.Background(), usr, folderId, orgID) + _, err := service.getFolderByID(context.Background(), usr, folderId, orgID) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) t.Run("When get folder by id, with id = 0 should return default folder", func(t *testing.T) { - folder, err := service.GetFolderByID(context.Background(), usr, 0, orgID) + foldr, err := service.getFolderByID(context.Background(), usr, 0, orgID) require.NoError(t, err) - require.Equal(t, folder, &models.Folder{Id: 0, Title: "General"}) + require.Equal(t, foldr, &folder.Folder{ID: 0, Title: "General"}) }) t.Run("When get folder by uid should return access denied error", func(t *testing.T) { - _, err := service.GetFolderByUID(context.Background(), usr, orgID, folderUID) + _, err := service.getFolderByUID(context.Background(), usr, orgID, folderUID) require.Equal(t, err, dashboards.ErrFolderAccessDenied) }) @@ -157,7 +158,7 @@ func TestIntegrationFolderService(t *testing.T) { t.Run("When creating folder should not return access denied error", func(t *testing.T) { dash := models.NewDashboardFolder("Test-Folder") dash.Id = rand.Int63() - f := models.DashboardToFolder(dash) + f := folder.FromDashboard(dash) dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dash, nil).Once() @@ -170,7 +171,7 @@ func TestIntegrationFolderService(t *testing.T) { UID: "someuid", }) require.NoError(t, err) - require.Equal(t, f, actualFolder.ToLegacyModel()) + require.Equal(t, f, actualFolder) }) t.Run("When creating folder should return error if uid is general", func(t *testing.T) { @@ -190,7 +191,7 @@ func TestIntegrationFolderService(t *testing.T) { dashboardFolder := models.NewDashboardFolder("Folder") dashboardFolder.Id = rand.Int63() dashboardFolder.Uid = util.GenerateShortUID() - f := models.DashboardToFolder(dashboardFolder) + f := folder.FromDashboard(dashboardFolder) dashStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.AnythingOfType("*models.Dashboard"), mock.AnythingOfType("bool")).Return(true, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(dashboardFolder, nil) @@ -207,10 +208,10 @@ func TestIntegrationFolderService(t *testing.T) { }) t.Run("When deleting folder by uid should not return access denied error", func(t *testing.T) { - f := models.NewFolder(util.GenerateShortUID()) - f.Id = rand.Int63() - f.Uid = util.GenerateShortUID() - dashStore.On("GetFolderByUID", mock.Anything, orgID, f.Uid).Return(f, nil) + f := folder.NewFolder(util.GenerateShortUID(), "") + f.ID = rand.Int63() + f.UID = util.GenerateShortUID() + dashStore.On("GetFolderByUID", mock.Anything, orgID, f.UID).Return(f, nil) var actualCmd *models.DeleteDashboardCommand dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { @@ -221,13 +222,13 @@ func TestIntegrationFolderService(t *testing.T) { ctx := context.Background() ctx = appcontext.WithUser(ctx, usr) err := service.DeleteFolder(ctx, &folder.DeleteFolderCommand{ - UID: f.Uid, + UID: f.UID, OrgID: orgID, ForceDeleteRules: expectedForceDeleteRules, }) require.NoError(t, err) require.NotNil(t, actualCmd) - require.Equal(t, f.Id, actualCmd.Id) + require.Equal(t, f.ID, actualCmd.Id) require.Equal(t, orgID, actualCmd.OrgId) require.Equal(t, expectedForceDeleteRules, actualCmd.ForceDeleteFolderRules) }) @@ -242,33 +243,33 @@ func TestIntegrationFolderService(t *testing.T) { guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) t.Run("When get folder by id should return folder", func(t *testing.T) { - expected := models.NewFolder(util.GenerateShortUID()) - expected.Id = rand.Int63() + expected := folder.NewFolder(util.GenerateShortUID(), "") + expected.ID = rand.Int63() - dashStore.On("GetFolderByID", mock.Anything, orgID, expected.Id).Return(expected, nil) + dashStore.On("GetFolderByID", mock.Anything, orgID, expected.ID).Return(expected, nil) - actual, err := service.GetFolderByID(context.Background(), usr, expected.Id, orgID) + actual, err := service.getFolderByID(context.Background(), usr, expected.ID, orgID) require.Equal(t, expected, actual) require.NoError(t, err) }) t.Run("When get folder by uid should return folder", func(t *testing.T) { - expected := models.NewFolder(util.GenerateShortUID()) - expected.Uid = util.GenerateShortUID() + expected := folder.NewFolder(util.GenerateShortUID(), "") + expected.UID = util.GenerateShortUID() - dashStore.On("GetFolderByUID", mock.Anything, orgID, expected.Uid).Return(expected, nil) + dashStore.On("GetFolderByUID", mock.Anything, orgID, expected.UID).Return(expected, nil) - actual, err := service.GetFolderByUID(context.Background(), usr, orgID, expected.Uid) + actual, err := service.getFolderByUID(context.Background(), usr, orgID, expected.UID) require.Equal(t, expected, actual) require.NoError(t, err) }) t.Run("When get folder by title should return folder", func(t *testing.T) { - expected := models.NewFolder("TEST-" + util.GenerateShortUID()) + expected := folder.NewFolder("TEST-"+util.GenerateShortUID(), "") dashStore.On("GetFolderByTitle", mock.Anything, orgID, expected.Title).Return(expected, nil) - actual, err := service.GetFolderByTitle(context.Background(), usr, orgID, expected.Title) + actual, err := service.getFolderByTitle(context.Background(), usr, orgID, expected.Title) require.Equal(t, expected, actual) require.NoError(t, err) }) @@ -311,7 +312,7 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) dashStore := dashboards.FakeDashboardStore{} dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) - dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) cfg := setting.NewCfg() cfg.RBACEnabled = false nestedFoldersEnabled := true @@ -338,18 +339,6 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { require.NotNil(t, res.UID) }) - t.Run("delete folder", func(t *testing.T) { - folderStore.ExpectedFolder = &folder.Folder{} - err := folderService.Delete(context.Background(), &folder.DeleteFolderCommand{}) - require.NoError(t, err) - }) - - t.Run("get folder", func(t *testing.T) { - folderStore.ExpectedFolder = &folder.Folder{} - _, err := folderService.Get(context.Background(), &folder.GetFolderQuery{}) - require.NoError(t, err) - }) - t.Run("get parents folder", func(t *testing.T) { folderStore.ExpectedFolder = &folder.Folder{} _, err := folderService.GetParents(context.Background(), &folder.GetParentsQuery{}) @@ -378,12 +367,6 @@ func TestNestedFolderServiceFeatureToggle(t *testing.T) { require.NoError(t, err) require.Equal(t, 4, len(res)) }) - - t.Run("move folder", func(t *testing.T) { - folderStore.ExpectedFolder = &folder.Folder{} - _, err := folderService.Move(context.Background(), &folder.MoveFolderCommand{}) - require.NoError(t, err) - }) } func TestNestedFolderService(t *testing.T) { @@ -412,7 +395,7 @@ func TestNestedFolderService(t *testing.T) { mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) - dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) ctx = appcontext.WithUser(ctx, usr) _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ @@ -430,7 +413,7 @@ func TestNestedFolderService(t *testing.T) { dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { actualCmd = args.Get(1).(*models.DeleteDashboardCommand) }).Return(nil).Once() - dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&models.Folder{}, nil) + dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&folder.Folder{}, nil) g := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) @@ -463,6 +446,9 @@ func TestNestedFolderService(t *testing.T) { dashboardStore: dashStore, store: store, features: features, + accessControl: actest.FakeAccessControl{ + ExpectedEvaluate: true, + }, } t.Run("create, no error", func(t *testing.T) { @@ -471,7 +457,7 @@ func TestNestedFolderService(t *testing.T) { mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) - dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) ctx = appcontext.WithUser(ctx, usr) _, err := foldersvc.Create(ctx, &folder.CreateFolderCommand{ OrgID: orgID, @@ -493,8 +479,8 @@ func TestNestedFolderService(t *testing.T) { mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool"), mock.AnythingOfType("bool")).Return(&models.SaveDashboardCommand{}, nil) dashStore.On("SaveDashboard", mock.Anything, mock.AnythingOfType("models.SaveDashboardCommand")).Return(&models.Dashboard{}, nil) - dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&models.Folder{}, nil) - dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&models.Folder{}, nil) + dashStore.On("GetFolderByID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(&folder.Folder{}, nil) + dashStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(&folder.Folder{}, nil) // return an error from the folder store store.ExpectedError = errors.New("FAILED") diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index 20c81534d78..ff62be316eb 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -14,29 +14,25 @@ type FakeService struct { ExpectedError error } +func NewFakeService() *FakeService { + return &FakeService{} +} + var _ folder.Service = (*FakeService)(nil) func (s *FakeService) GetFolders(ctx context.Context, user *user.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) { return s.ExpectedFolders, s.ExpectedError } -func (s *FakeService) GetFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*models.Folder, error) { - return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError -} -func (s *FakeService) GetFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*models.Folder, error) { - if s.ExpectedFolder == nil { - return nil, s.ExpectedError - } - return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError -} -func (s *FakeService) GetFolderByTitle(ctx context.Context, user *user.SignedInUser, orgID int64, title string) (*models.Folder, error) { - return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError -} + func (s *FakeService) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } -func (s *FakeService) Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*models.Folder, error) { +func (s *FakeService) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { + return s.ExpectedFolder, s.ExpectedError +} +func (s *FakeService) Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*folder.Folder, error) { cmd.Result = s.ExpectedFolder.ToLegacyModel() - return s.ExpectedFolder.ToLegacyModel(), s.ExpectedError + return s.ExpectedFolder, s.ExpectedError } func (s *FakeService) DeleteFolder(ctx context.Context, cmd *folder.DeleteFolderCommand) error { return s.ExpectedError diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index d91ba445066..05c20196ff5 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -9,13 +9,18 @@ import ( type Service interface { GetFolders(ctx context.Context, user *user.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) - GetFolderByID(ctx context.Context, user *user.SignedInUser, id int64, orgID int64) (*models.Folder, error) - GetFolderByUID(ctx context.Context, user *user.SignedInUser, orgID int64, uid string) (*models.Folder, error) - GetFolderByTitle(ctx context.Context, user *user.SignedInUser, orgID int64, title string) (*models.Folder, error) + Create(ctx context.Context, cmd *CreateFolderCommand) (*Folder, error) + + // GetFolder takes a GetFolderCommand and returns a folder matching the + // request. One of ID, UID, or Title must be included. If multiple values + // are included in the request, Grafana will select one in order of + // specificity (ID, UID, Title). + Get(ctx context.Context, cmd *GetFolderQuery) (*Folder, error) + // Update is used to update a folder's UID, Title and Description. To change // a folder's parent folder, use Move. - Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*models.Folder, error) + Update(ctx context.Context, user *user.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) (*Folder, error) DeleteFolder(ctx context.Context, cmd *DeleteFolderCommand) error MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error // Move changes a folder's parent folder to the requested new parent. @@ -35,12 +40,6 @@ type NestedFolderService interface { // dashboards in the folder. Delete(ctx context.Context, cmd *DeleteFolderCommand) (*Folder, error) - // GetFolder takes a GetFolderCommand and returns a folder matching the - // request. One of ID, UID, or Title must be included. If multiple values - // are included in the request, Grafana will select one in order of - // specificity (ID, UID, Title). - Get(ctx context.Context, cmd *GetFolderQuery) (*Folder, error) - // GetParents returns an ordered list of parent folders for the given // folder, starting with the root node and ending with the requested child // node. diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index 3cb3ef4b5cc..d499390b593 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/web" ) @@ -47,11 +48,11 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res if *cmd.FolderUID == "" { cmd.FolderID = 0 } else { - folder, err := l.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgID, *cmd.FolderUID) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: cmd.FolderUID}) if err != nil || folder == nil { return response.Error(http.StatusBadRequest, "failed to get folder", err) } - cmd.FolderID = folder.Id + cmd.FolderID = folder.ID } } @@ -61,12 +62,12 @@ func (l *LibraryElementService) createHandler(c *models.ReqContext) response.Res } if element.FolderID != 0 { - folder, err := l.folderService.GetFolderByID(c.Req.Context(), c.SignedInUser, element.FolderID, c.OrgID) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, ID: &element.FolderID}) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get folder", err) } - element.FolderUID = folder.Uid - element.Meta.FolderUID = folder.Uid + element.FolderUID = folder.UID + element.Meta.FolderUID = folder.UID element.Meta.FolderName = folder.Title } @@ -175,11 +176,11 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp if *cmd.FolderUID == "" { cmd.FolderID = 0 } else { - folder, err := l.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgID, *cmd.FolderUID) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, UID: cmd.FolderUID}) if err != nil || folder == nil { return response.Error(http.StatusBadRequest, "failed to get folder", err) } - cmd.FolderID = folder.Id + cmd.FolderID = folder.ID } } @@ -189,12 +190,12 @@ func (l *LibraryElementService) patchHandler(c *models.ReqContext) response.Resp } if element.FolderID != 0 { - folder, err := l.folderService.GetFolderByID(c.Req.Context(), c.SignedInUser, element.FolderID, c.OrgID) + folder, err := l.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.OrgID, ID: &element.FolderID}) if err != nil { return response.Error(http.StatusInternalServerError, "failed to get folder", err) } - element.FolderUID = folder.Uid - element.Meta.FolderUID = folder.Uid + element.FolderUID = folder.UID + element.Meta.FolderUID = folder.UID element.Meta.FolderName = folder.Title } diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go index ed697a4bf33..958ded662d6 100644 --- a/pkg/services/libraryelements/guard.go +++ b/pkg/services/libraryelements/guard.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -39,12 +40,12 @@ func (l *LibraryElementService) requireEditPermissionsOnFolder(ctx context.Conte if isGeneralFolder(folderID) && user.HasRole(org.RoleViewer) { return dashboards.ErrFolderAccessDenied } - folder, err := l.folderService.GetFolderByID(ctx, user, folderID, user.OrgID) + folder, err := l.folderService.Get(ctx, &folder.GetFolderQuery{ID: &folderID, OrgID: user.OrgID}) if err != nil { return err } - g := guardian.New(ctx, folder.Id, user.OrgID, user) + g := guardian.New(ctx, folder.ID, user.OrgID, user) canEdit, err := g.CanEdit() if err != nil { @@ -62,12 +63,12 @@ func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Conte return nil } - folder, err := l.folderService.GetFolderByID(ctx, user, folderID, user.OrgID) + folder, err := l.folderService.Get(ctx, &folder.GetFolderQuery{ID: &folderID, OrgID: user.OrgID}) if err != nil { return err } - g := guardian.New(ctx, folder.Id, user.OrgID, user) + g := guardian.New(ctx, folder.ID, user.OrgID, user) canView, err := g.CanView() if err != nil { diff --git a/pkg/services/libraryelements/libraryelements_create_test.go b/pkg/services/libraryelements/libraryelements_create_test.go index afde9ceaf09..ecc54aca467 100644 --- a/pkg/services/libraryelements/libraryelements_create_test.go +++ b/pkg/services/libraryelements/libraryelements_create_test.go @@ -13,7 +13,7 @@ import ( func TestCreateLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to create a library panel that already exists, it should fail", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 400, resp.Status()) @@ -65,7 +65,7 @@ func TestCreateLibraryElement(t *testing.T) { testScenario(t, "When an admin tries to create a library panel that does not exists using an nonexistent UID, it should succeed", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Nonexistent UID") + command := getCreatePanelCommand(sc.folder.ID, "Nonexistent UID") command.UID = util.GenerateShortUID() sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) @@ -114,7 +114,7 @@ func TestCreateLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to create a library panel that does not exists using an existent UID, it should fail", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Existing UID") + command := getCreatePanelCommand(sc.folder.ID, "Existing UID") command.UID = sc.initialResult.Result.UID sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) @@ -123,7 +123,7 @@ func TestCreateLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to create a library panel that does not exists using an invalid UID, it should fail", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Invalid UID") + command := getCreatePanelCommand(sc.folder.ID, "Invalid UID") command.UID = "Testing an invalid UID" sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) @@ -132,7 +132,7 @@ func TestCreateLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to create a library panel that does not exists using an UID that is too long, it should fail", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Invalid UID") + command := getCreatePanelCommand(sc.folder.ID, "Invalid UID") command.UID = "j6T00KRZzj6T00KRZzj6T00KRZzj6T00KRZzj6T00K" sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) diff --git a/pkg/services/libraryelements/libraryelements_delete_test.go b/pkg/services/libraryelements/libraryelements_delete_test.go index 5f3065ebf50..0bd640e646d 100644 --- a/pkg/services/libraryelements/libraryelements_delete_test.go +++ b/pkg/services/libraryelements/libraryelements_delete_test.go @@ -73,7 +73,7 @@ func TestDeleteLibraryElement(t *testing.T) { Title: "Testing deleteHandler ", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.Id) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_get_all_test.go b/pkg/services/libraryelements/libraryelements_get_all_test.go index 9d0970a8d3a..bac68141c57 100644 --- a/pkg/services/libraryelements/libraryelements_get_all_test.go +++ b/pkg/services/libraryelements/libraryelements_get_all_test.go @@ -37,7 +37,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all panel elements and both panels and variables exist, it should only return panels", func(t *testing.T, sc scenarioContext) { - command := getCreateVariableCommand(sc.folder.Id, "query0") + command := getCreateVariableCommand(sc.folder.ID, "query0") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -77,7 +77,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -103,7 +103,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all variable elements and both panels and variables exist, it should only return panels", func(t *testing.T, sc scenarioContext) { - command := getCreateVariableCommand(sc.folder.Id, "query0") + command := getCreateVariableCommand(sc.folder.ID, "query0") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -142,7 +142,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -168,7 +168,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist, it should succeed", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -204,7 +204,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -239,7 +239,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[1].Meta.Created, Updated: result.Result.Elements[1].Meta.Updated, @@ -265,7 +265,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and sort desc is set, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -304,7 +304,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -339,7 +339,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[1].Meta.Created, Updated: result.Result.Elements[1].Meta.Updated, @@ -365,7 +365,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and typeFilter is set to existing types, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", models.PanelElement, []byte(` + command := getCreateCommandWithModel(sc.folder.ID, "Gauge - Library Panel", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -378,7 +378,7 @@ func TestGetAllLibraryElements(t *testing.T) { resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) - command = getCreateCommandWithModel(sc.folder.Id, "BarGauge - Library Panel", models.PanelElement, []byte(` + command = getCreateCommandWithModel(sc.folder.ID, "BarGauge - Library Panel", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -425,7 +425,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -460,7 +460,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[1].Meta.Created, Updated: result.Result.Elements[1].Meta.Updated, @@ -486,7 +486,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and typeFilter is set to a nonexistent type, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", models.PanelElement, []byte(` + command := getCreateCommandWithModel(sc.folder.ID, "Gauge - Library Panel", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -621,7 +621,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to General folder, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -661,7 +661,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -696,7 +696,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[1].Meta.Created, Updated: result.Result.Elements[1].Meta.Updated, @@ -722,7 +722,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and excludeUID is set, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -761,7 +761,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -787,7 +787,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -826,7 +826,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -852,7 +852,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 2, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -892,7 +892,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -918,7 +918,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and searchString exists in the description, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Text - Library Panel2", models.PanelElement, []byte(` + command := getCreateCommandWithModel(sc.folder.ID, "Text - Library Panel2", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -967,7 +967,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -993,7 +993,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and searchString exists in both name and description, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Some Other", models.PanelElement, []byte(` + command := getCreateCommandWithModel(sc.folder.ID, "Some Other", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -1040,7 +1040,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -1075,7 +1075,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[1].Meta.Created, Updated: result.Result.Elements[1].Meta.Updated, @@ -1101,7 +1101,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 1 and searchString is panel2, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -1142,7 +1142,7 @@ func TestGetAllLibraryElements(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: result.Result.Elements[0].Meta.Created, Updated: result.Result.Elements[0].Meta.Updated, @@ -1168,7 +1168,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 3 and searchString is panel, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -1199,7 +1199,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 3 and searchString does not exist, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel2") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel2") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index 96e9d2a180e..a1ae8276351 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -49,7 +49,7 @@ func TestGetLibraryElement(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 0, Created: res.Result.Meta.Created, Updated: res.Result.Meta.Updated, @@ -119,7 +119,7 @@ func TestGetLibraryElement(t *testing.T) { Title: "Testing getHandler", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.Id) require.NoError(t, err) @@ -144,7 +144,7 @@ func TestGetLibraryElement(t *testing.T) { Version: 1, Meta: LibraryElementDTOMeta{ FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, + FolderUID: sc.folder.UID, ConnectedDashboards: 1, Created: res.Result.Meta.Created, Updated: res.Result.Meta.Updated, diff --git a/pkg/services/libraryelements/libraryelements_patch_test.go b/pkg/services/libraryelements/libraryelements_patch_test.go index 998a5c59ab8..51d1f06e822 100644 --- a/pkg/services/libraryelements/libraryelements_patch_test.go +++ b/pkg/services/libraryelements/libraryelements_patch_test.go @@ -187,7 +187,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with an existing UID, it should fail", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Existing UID") + command := getCreatePanelCommand(sc.folder.ID, "Existing UID") command.UID = util.GenerateShortUID() sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) @@ -307,7 +307,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with a name that already exists, it should fail", func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Another Panel") + command := getCreatePanelCommand(sc.folder.ID, "Another Panel") sc.ctx.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) var result = validateAndUnMarshalResponse(t, resp) @@ -343,7 +343,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel in another org, it should fail", func(t *testing.T, sc scenarioContext) { cmd := PatchLibraryElementCommand{ - FolderID: sc.folder.Id, + FolderID: sc.folder.ID, Version: 1, Kind: int64(models.PanelElement), } @@ -357,7 +357,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with an old version number, it should fail", func(t *testing.T, sc scenarioContext) { cmd := PatchLibraryElementCommand{ - FolderID: sc.folder.Id, + FolderID: sc.folder.ID, Version: 1, Kind: int64(models.PanelElement), } @@ -373,7 +373,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel with an other kind, it should succeed but panel should not change", func(t *testing.T, sc scenarioContext) { cmd := PatchLibraryElementCommand{ - FolderID: sc.folder.Id, + FolderID: sc.folder.ID, Version: 1, Kind: int64(models.VariableElement), } diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index a497c0dfbfe..944dfeb650e 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -73,23 +73,23 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) { Title: "Testing DeleteLibraryElementsInFolder", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.Id) require.NoError(t, err) - err = sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.Uid) + err = sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID) require.EqualError(t, err, ErrFolderHasConnectedLibraryElements.Error()) }) scenarioWithPanel(t, "When an admin tries to delete a folder uid that doesn't exist, it should fail", func(t *testing.T, sc scenarioContext) { - err := sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.Uid+"xxxx") + err := sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID+"xxxx") require.EqualError(t, err, dashboards.ErrFolderNotFound.Error()) }) scenarioWithPanel(t, "When an admin tries to delete a folder that contains disconnected elements, it should delete all disconnected elements too", func(t *testing.T, sc scenarioContext) { - command := getCreateVariableCommand(sc.folder.Id, "query0") + command := getCreateVariableCommand(sc.folder.ID, "query0") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -102,7 +102,7 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) { require.NotNil(t, result.Result) require.Equal(t, 2, len(result.Result.Elements)) - err = sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.Uid) + err = sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID) require.NoError(t, err) resp = sc.service.getAllHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -146,7 +146,7 @@ func TestGetLibraryPanelConnections(t *testing.T) { Title: "Testing GetLibraryPanelConnections", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.Id) require.NoError(t, err) @@ -256,7 +256,7 @@ type scenarioContext struct { service *LibraryElementService reqContext *models.ReqContext user user.SignedInUser - folder *models.Folder + folder *folder.Folder initialResult libraryElementResult sqlStore db.DB } @@ -387,7 +387,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena guardian.InitLegacyGuardian(store, &dashboards.FakeDashboardService{}, &teamtest.FakeService{}) testScenario(t, desc, func(t *testing.T, sc scenarioContext) { - command := getCreatePanelCommand(sc.folder.Id, "Text - Library Panel") + command := getCreatePanelCommand(sc.folder.ID, "Text - Library Panel") sc.reqContext.Req.Body = mockRequestBody(command) resp := sc.service.createHandler(sc.reqContext) sc.initialResult = validateAndUnMarshalResponse(t, resp) @@ -402,13 +402,26 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo t.Helper() t.Run(desc, func(t *testing.T) { - ctx := web.Context{Req: &http.Request{ + orgID := int64(1) + role := org.RoleAdmin + usr := user.SignedInUser{ + UserID: 1, + Name: "Signed In User", + Login: "signed_in_user", + Email: "signed.in.user@test.com", + OrgID: orgID, + OrgRole: role, + LastSeenAt: time.Now(), + } + req := &http.Request{ Header: http.Header{ "Content-Type": []string{"application/json"}, }, - }} - orgID := int64(1) - role := org.RoleAdmin + } + ctx := appcontext.WithUser(context.Background(), &usr) + req = req.WithContext(ctx) + webCtx := web.Context{Req: req} + sqlStore := db.InitTestDB(t) dashboardStore := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) features := featuremgmt.WithFeatures() @@ -428,16 +441,6 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardService, dashboardStore, nil, features, folderPermissions, nil), } - usr := user.SignedInUser{ - UserID: 1, - Name: "Signed In User", - Login: "signed_in_user", - Email: "signed.in.user@test.com", - OrgID: orgID, - OrgRole: role, - LastSeenAt: time.Now(), - } - // deliberate difference between signed in user and user in db to make it crystal clear // what to expect in the tests // In the real world these are identical @@ -452,16 +455,16 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo sc := scenarioContext{ user: usr, - ctx: &ctx, + ctx: &webCtx, service: &service, sqlStore: sqlStore, reqContext: &models.ReqContext{ - Context: &ctx, + Context: &webCtx, SignedInUser: &usr, }, } - sc.folder = createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}).ToLegacyModel() + sc.folder = createFolderWithACL(t, sc.sqlStore, "ScenarioFolder", sc.user, []folderACLItem{}) fn(t, sc) }) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 007a7ed33e5..7f7bf97bebd 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -850,12 +850,14 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Login: userInDbName, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + ctx := appcontext.WithUser(context.Background(), usr) + + _, err := sqlStore.CreateUser(ctx, cmd) require.NoError(t, err) sc := scenarioContext{ user: usr, - ctx: context.Background(), + ctx: ctx, service: &service, elementService: elementService, sqlStore: sqlStore, diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index d263a340eda..c894258bf20 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -181,7 +182,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res return response.JSON(http.StatusOK, ruleResponse) } -func (srv PrometheusSrv) toRuleGroup(groupName string, folder *models.Folder, rules []*ngmodels.AlertRule, labelOptions []ngmodels.LabelOption) *apimodels.RuleGroup { +func (srv PrometheusSrv) toRuleGroup(groupName string, folder *folder.Folder, rules []*ngmodels.AlertRule, labelOptions []ngmodels.LabelOption) *apimodels.RuleGroup { newGroup := &apimodels.RuleGroup{ Name: groupName, File: folder.Title, // file is what Prometheus uses for provisioning, we replace it with namespace. diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 9229bd40f78..6161bce3bb4 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -83,7 +83,7 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext, namespaceTitle s unauthz, provisioned := false, false q := ngmodels.ListAlertRulesQuery{ OrgID: c.SignedInUser.OrgID, - NamespaceUIDs: []string{namespace.Uid}, + NamespaceUIDs: []string{namespace.UID}, RuleGroup: ruleGroup, } if err = srv.store.ListAlertRules(ctx, &q); err != nil { @@ -163,7 +163,7 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext, namespace q := ngmodels.ListAlertRulesQuery{ OrgID: c.SignedInUser.OrgID, - NamespaceUIDs: []string{namespace.Uid}, + NamespaceUIDs: []string{namespace.UID}, } if err := srv.store.ListAlertRules(c.Req.Context(), &q); err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") @@ -189,7 +189,7 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext, namespace if !authorizeAccessToRuleGroup(rules, hasAccess) { continue } - result[namespaceTitle] = append(result[namespaceTitle], toGettableRuleGroupConfig(groupName, rules, namespace.Id, provenanceRecords)) + result[namespaceTitle] = append(result[namespaceTitle], toGettableRuleGroupConfig(groupName, rules, namespace.ID, provenanceRecords)) } return response.JSON(http.StatusAccepted, result) @@ -205,7 +205,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *models.ReqContext, namespaceTitl q := ngmodels.ListAlertRulesQuery{ OrgID: c.SignedInUser.OrgID, - NamespaceUIDs: []string{namespace.Uid}, + NamespaceUIDs: []string{namespace.UID}, RuleGroup: ruleGroup, } if err := srv.store.ListAlertRules(c.Req.Context(), &q); err != nil { @@ -226,7 +226,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *models.ReqContext, namespaceTitl } result := apimodels.RuleGroupConfigResponse{ - GettableRuleGroupConfig: toGettableRuleGroupConfig(ruleGroup, q.Result, namespace.Id, provenanceRecords), + GettableRuleGroupConfig: toGettableRuleGroupConfig(ruleGroup, q.Result, namespace.ID, provenanceRecords), } return response.JSON(http.StatusAccepted, result) } @@ -296,7 +296,7 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response continue } namespace := folder.Title - result[namespace] = append(result[namespace], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, folder.Id, provenanceRecords)) + result[namespace] = append(result[namespace], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, folder.ID, provenanceRecords)) } return response.JSON(http.StatusOK, result) } @@ -316,7 +316,7 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf groupKey := ngmodels.AlertRuleGroupKey{ OrgID: c.SignedInUser.OrgID, - NamespaceUID: namespace.Uid, + NamespaceUID: namespace.UID, RuleGroup: ruleGroupConfig.Name, } diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 4a7b2a4cc64..9f7d07103a2 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" @@ -382,7 +383,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) - groupKey.NamespaceUID = folder.Uid + groupKey.NamespaceUID = folder.UID expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) ruleStore.PutRule(context.Background(), expectedRules...) @@ -426,12 +427,12 @@ func TestRouteGetRulesConfig(t *testing.T) { ruleStore := fakes.NewRuleStore(t) folder1 := randFolder() folder2 := randFolder() - ruleStore.Folders[orgID] = []*models2.Folder{folder1, folder2} + ruleStore.Folders[orgID] = []*folder.Folder{folder1, folder2} group1Key := models.GenerateGroupKey(orgID) - group1Key.NamespaceUID = folder1.Uid + group1Key.NamespaceUID = folder1.UID group2Key := models.GenerateGroupKey(orgID) - group2Key.NamespaceUID = folder2.Uid + group2Key.NamespaceUID = folder2.UID group1 := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(group1Key))) group2 := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(group2Key))) @@ -464,7 +465,7 @@ func TestRouteGetRulesConfig(t *testing.T) { ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) - groupKey.NamespaceUID = folder.Uid + groupKey.NamespaceUID = folder.UID expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) ruleStore.PutRule(context.Background(), expectedRules...) @@ -509,7 +510,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) { ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) - groupKey.NamespaceUID = folder.Uid + groupKey.NamespaceUID = folder.UID expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))) ruleStore.PutRule(context.Background(), expectedRules...) @@ -544,7 +545,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) { ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) groupKey := models.GenerateGroupKey(orgID) - groupKey.NamespaceUID = folder.Uid + groupKey.NamespaceUID = folder.UID expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) ruleStore.PutRule(context.Background(), expectedRules...) @@ -699,9 +700,9 @@ func withGroup(groupName string) func(rule *models.AlertRule) { } } -func withNamespace(namespace *models2.Folder) func(rule *models.AlertRule) { +func withNamespace(namespace *folder.Folder) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { - rule.NamespaceUID = namespace.Uid + rule.NamespaceUID = namespace.UID } } diff --git a/pkg/services/ngalert/api/api_ruler_validation.go b/pkg/services/ngalert/api/api_ruler_validation.go index 395beaa0291..903188c496b 100644 --- a/pkg/services/ngalert/api/api_ruler_validation.go +++ b/pkg/services/ngalert/api/api_ruler_validation.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -18,7 +18,7 @@ func validateRuleNode( groupName string, interval time.Duration, orgId int64, - namespace *models.Folder, + namespace *folder.Folder, conditionValidator func(ngmodels.Condition) error, cfg *setting.UnifiedAlertingSettings) (*ngmodels.AlertRule, error) { intervalSeconds, err := validateInterval(cfg, interval) @@ -93,7 +93,7 @@ func validateRuleNode( Data: ruleNode.GrafanaManagedAlert.Data, UID: ruleNode.GrafanaManagedAlert.UID, IntervalSeconds: intervalSeconds, - NamespaceUID: namespace.Uid, + NamespaceUID: namespace.UID, RuleGroup: groupName, NoDataState: noDataState, ExecErrState: errorState, @@ -152,7 +152,7 @@ func validateForInterval(ruleNode *apimodels.PostableExtendedRuleNode) (time.Dur func validateRuleGroup( ruleGroupConfig *apimodels.PostableRuleGroupConfig, orgId int64, - namespace *models.Folder, + namespace *folder.Folder, conditionValidator func(ngmodels.Condition) error, cfg *setting.UnifiedAlertingSettings) ([]*ngmodels.AlertRule, error) { if ruleGroupConfig.Name == "" { diff --git a/pkg/services/ngalert/api/api_ruler_validation_test.go b/pkg/services/ngalert/api/api_ruler_validation_test.go index bd4072a59c0..b5098d167f4 100644 --- a/pkg/services/ngalert/api/api_ruler_validation_test.go +++ b/pkg/services/ngalert/api/api_ruler_validation_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/exp/rand" - models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -83,18 +83,18 @@ func validGroup(cfg *setting.UnifiedAlertingSettings, rules ...apimodels.Postabl } } -func randFolder() *models2.Folder { - return &models2.Folder{ - Id: rand.Int63(), - Uid: util.GenerateShortUID(), - Title: "TEST-FOLDER-" + util.GenerateShortUID(), - Url: "", - Version: 0, - Created: time.Time{}, - Updated: time.Time{}, - UpdatedBy: 0, - CreatedBy: 0, - HasACL: false, +func randFolder() *folder.Folder { + return &folder.Folder{ + ID: rand.Int63(), + UID: util.GenerateShortUID(), + Title: "TEST-FOLDER-" + util.GenerateShortUID(), + // URL: "", + // Version: 0, + Created: time.Time{}, + Updated: time.Time{}, + // UpdatedBy: 0, + // CreatedBy: 0, + // HasACL: false, } } @@ -235,7 +235,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { require.Equal(t, int64(interval.Seconds()), alert.IntervalSeconds) require.Equal(t, int64(0), alert.Version) require.Equal(t, api.GrafanaManagedAlert.UID, alert.UID) - require.Equal(t, folder.Uid, alert.NamespaceUID) + require.Equal(t, folder.UID, alert.NamespaceUID) require.Nil(t, alert.DashboardUID) require.Nil(t, alert.PanelID) require.Equal(t, name, alert.RuleGroup) diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index bb8f59c7412..6c3b03576c3 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -3,15 +3,15 @@ package api import ( "context" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/user" ) // RuleStore is the interface for persisting alert rules and instances type RuleStore interface { - GetUserVisibleNamespaces(context.Context, int64, *user.SignedInUser) (map[string]*models.Folder, error) - GetNamespaceByTitle(context.Context, string, int64, *user.SignedInUser, bool) (*models.Folder, error) + GetUserVisibleNamespaces(context.Context, int64, *user.SignedInUser) (map[string]*folder.Folder, error) + GetNamespaceByTitle(context.Context, string, int64, *user.SignedInUser, bool) (*folder.Folder, error) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) error ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 782f76d9b58..f1c343a75bc 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/expr" - models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/util" ) @@ -140,9 +140,9 @@ func WithOrgID(orgId int64) AlertRuleMutator { } } -func WithNamespace(namespace *models2.Folder) AlertRuleMutator { +func WithNamespace(namespace *folder.Folder) AlertRuleMutator { return func(rule *AlertRule) { - rule.NamespaceUID = namespace.Uid + rule.NamespaceUID = namespace.UID } } diff --git a/pkg/services/ngalert/ngalert_test.go b/pkg/services/ngalert/ngalert_test.go index 7004198e1eb..2d70e25c5ce 100644 --- a/pkg/services/ngalert/ngalert_test.go +++ b/pkg/services/ngalert/ngalert_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" @@ -22,9 +22,9 @@ import ( func Test_subscribeToFolderChanges(t *testing.T) { orgID := rand.Int63() - folder := &models2.Folder{ - Id: 0, - Uid: util.GenerateShortUID(), + folder := &folder.Folder{ + ID: 0, + UID: util.GenerateShortUID(), Title: "Folder" + util.GenerateShortUID(), } rules := models.GenerateAlertRules(5, models.AlertRuleGen(models.WithOrgID(orgID), models.WithNamespace(folder))) @@ -42,8 +42,8 @@ func Test_subscribeToFolderChanges(t *testing.T) { err := bus.Publish(context.Background(), &events.FolderTitleUpdated{ Timestamp: time.Now(), Title: "Folder" + util.GenerateShortUID(), - ID: folder.Id, - UID: folder.Uid, + ID: folder.ID, + UID: folder.UID, OrgID: orgID, }) require.NoError(t, err) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 592a50689b6..1cb125de37c 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -298,8 +299,8 @@ func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespa } // GetUserVisibleNamespaces returns the folders that are visible to the user and have at least one alert in it -func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user *user.SignedInUser) (map[string]*models.Folder, error) { - namespaceMap := make(map[string]*models.Folder) +func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user *user.SignedInUser) (map[string]*folder.Folder, error) { + namespaceMap := make(map[string]*folder.Folder) searchQuery := models.FindPersistedDashboardsQuery{ OrgId: orgID, @@ -330,9 +331,9 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use if !hit.IsFolder { continue } - namespaceMap[hit.UID] = &models.Folder{ - Id: hit.ID, - Uid: hit.UID, + namespaceMap[hit.UID] = &folder.Folder{ + ID: hit.ID, + UID: hit.UID, Title: hit.Title, } } @@ -342,15 +343,15 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use } // GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces. -func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user *user.SignedInUser, withCanSave bool) (*models.Folder, error) { - folder, err := st.FolderService.GetFolderByTitle(ctx, user, orgID, namespace) +func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user *user.SignedInUser, withCanSave bool) (*folder.Folder, error) { + folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &namespace}) if err != nil { return nil, err } // if access control is disabled, check that the user is allowed to save in the folder. if withCanSave && st.AccessControl.IsDisabled() { - g := guardian.New(ctx, folder.Id, orgID, user) + g := guardian.New(ctx, folder.ID, orgID, user) if canSave, err := g.CanSave(); err != nil || !canSave { if err != nil { st.Logger.Error("checking can save permission has failed", "userId", user.UserID, "username", user.Login, "namespace", namespace, "orgId", orgID, "error", err) @@ -363,8 +364,8 @@ func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, org } // GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces. -func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user *user.SignedInUser) (*models.Folder, error) { - folder, err := st.FolderService.GetFolderByUID(ctx, user, orgID, uid) +func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user *user.SignedInUser) (*folder.Folder, error) { + folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &uid}) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index a4d4be01b67..1471109ba4a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -22,7 +22,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { t.Skip("skipping integration test") } sqlStore := db.InitTestDB(t) - store := DBstore{ + store := &DBstore{ SQLStore: sqlStore, Cfg: setting.UnifiedAlertingSettings{ BaseInterval: time.Duration(rand.Int63n(100)) * time.Second, @@ -136,7 +136,7 @@ func TestIntegration_CountAlertRules(t *testing.T) { } sqlStore := db.InitTestDB(t) - store := DBstore{SQLStore: sqlStore} + store := &DBstore{SQLStore: sqlStore} rule := createRule(t, store) tests := map[string]struct { @@ -175,7 +175,7 @@ func TestIntegration_CountAlertRules(t *testing.T) { } } -func createRule(t *testing.T, store DBstore) *models.AlertRule { +func createRule(t *testing.T, store *DBstore) *models.AlertRule { rule := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval))() err := store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error { _, err := sess.Table(models.AlertRule{}).InsertOne(rule) diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 3cb2f1b7210..3775f4a28d3 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -9,7 +9,7 @@ import ( "testing" "time" - models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -23,7 +23,7 @@ type RuleStore struct { Rules map[int64][]*models.AlertRule Hook func(cmd interface{}) error // use Hook if you need to intercept some query and return an error RecordedOps []interface{} - Folders map[int64][]*models2.Folder + Folders map[int64][]*folder.Folder } type GenericRecordedQuery struct { @@ -38,7 +38,7 @@ func NewRuleStore(t *testing.T) *RuleStore { Hook: func(interface{}) error { return nil }, - Folders: map[int64][]*models2.Folder{}, + Folders: map[int64][]*folder.Folder{}, } } @@ -58,18 +58,18 @@ mainloop: rgs = append(rgs, r) f.Rules[r.OrgID] = rgs - var existing *models2.Folder + var existing *folder.Folder folders := f.Folders[r.OrgID] for _, folder := range folders { - if folder.Uid == r.NamespaceUID { + if folder.UID == r.NamespaceUID { existing = folder break } } if existing == nil { - folders = append(folders, &models2.Folder{ - Id: rand.Int63(), - Uid: r.NamespaceUID, + folders = append(folders, &folder.Folder{ + ID: rand.Int63(), + UID: r.NamespaceUID, Title: "TEST-FOLDER-" + util.GenerateShortUID(), }) f.Folders[r.OrgID] = folders @@ -227,11 +227,11 @@ func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQu return nil } -func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ *user.SignedInUser) (map[string]*models2.Folder, error) { +func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ *user.SignedInUser) (map[string]*folder.Folder, error) { f.mtx.Lock() defer f.mtx.Unlock() - namespacesMap := map[string]*models2.Folder{} + namespacesMap := map[string]*folder.Folder{} _, ok := f.Rules[orgID] if !ok { @@ -239,12 +239,12 @@ func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ * } for _, folder := range f.Folders[orgID] { - namespacesMap[folder.Uid] = folder + namespacesMap[folder.UID] = folder } return namespacesMap, nil } -func (f *RuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID int64, _ *user.SignedInUser, _ bool) (*models2.Folder, error) { +func (f *RuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID int64, _ *user.SignedInUser, _ bool) (*folder.Folder, error) { folders := f.Folders[orgID] for _, folder := range folders { if folder.Title == title { @@ -254,7 +254,7 @@ func (f *RuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID i return nil, fmt.Errorf("not found") } -func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, _ *user.SignedInUser) (*models2.Folder, error) { +func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, _ *user.SignedInUser) (*folder.Folder, error) { f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{ Name: "GetNamespaceByUID", Params: []interface{}{orgID, uid}, @@ -262,7 +262,7 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64 folders := f.Folders[orgID] for _, folder := range folders { - if folder.Uid == uid { + if folder.UID == uid { return folder, nil } } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 921cd630703..287dad05e3b 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - gfmodels "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -68,6 +67,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, }) cfg := setting.NewCfg() + cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{ BaseInterval: setting.SchedulerBaseInterval, } @@ -127,12 +127,10 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s } ctx = appcontext.WithUser(ctx, user) - f, err := dbstore.FolderService.Create(ctx, &folder.CreateFolderCommand{OrgID: orgID, Title: "FOLDER-" + util.GenerateShortUID(), UID: folderUID}) - var folder *gfmodels.Folder - if err == nil { - folder = f.ToLegacyModel() - } else if errors.Is(err, dashboards.ErrFolderWithSameUIDExists) || errors.Is(err, dashboards.ErrFolderVersionMismatch) { - folder, err = dbstore.FolderService.GetFolderByUID(ctx, user, orgID, folderUID) + _, err := dbstore.FolderService.Create(ctx, &folder.CreateFolderCommand{OrgID: orgID, Title: "FOLDER-" + util.GenerateShortUID(), UID: folderUID}) + // var foldr *folder.Folder + if errors.Is(err, dashboards.ErrFolderWithSameUIDExists) || errors.Is(err, dashboards.ErrFolderVersionMismatch) { + _, err = dbstore.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, UID: &folderUID}) } require.NoError(t, err) @@ -160,7 +158,7 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s Labels: labels, Annotations: map[string]string{"testAnnoKey": "testAnnoValue"}, IntervalSeconds: intervalSeconds, - NamespaceUID: folder.Uid, + NamespaceUID: folderUID, RuleGroup: ruleGroup, NoDataState: models.NoData, ExecErrState: models.AlertingErrState, @@ -170,7 +168,7 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s q := models.ListAlertRulesQuery{ OrgID: orgID, - NamespaceUIDs: []string{folder.Uid}, + NamespaceUIDs: []string{folderUID}, RuleGroup: ruleGroup, } err = dbstore.ListAlertRules(ctx, &q) @@ -178,6 +176,6 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s require.NotEmpty(t, q.Result) rule := q.Result[0] - t.Logf("alert definition: %v with title: %q interval: %d folder: %s created", rule.GetKey(), rule.Title, rule.IntervalSeconds, folder.Uid) + t.Logf("alert definition: %v with title: %q interval: %d folder: %s created", rule.GetKey(), rule.Title, rule.IntervalSeconds, folderUID) return rule } From e5cb1ceae0c6becbd545fe97c4977b0d72d4447c Mon Sep 17 00:00:00 2001 From: Virginia Cepeda Date: Fri, 11 Nov 2022 10:29:59 -0300 Subject: [PATCH 203/926] Alerting: Suggest previously entered custom labels (#57783) * [Alerting] - replace label inputs with dropdowns (#57019) * Add AlertLabelDropdown component It will be used to pick from or create new labels * Adapt LabelsField component to use AlertLabelDropdown instead of inputs * Add tests for LabelsField component Plus a few other tests were adapted to work with the label dropdowns * Use ref in component * Fix showing placeholders in the label dropdowns * Minor syntax change * Remove unneeded import after rebase * Display custom labels When a label key is selected, its corresponding values are shown in the dropdown * Add tooltip explaining where labels in the dropdowns come from * Fix import of Stack component * Avoid duplicated values * Improvements based on review * Display labels for currently selected datasource only * Refactor AlertsField to allow to choose whether to suggest labels or not * Suggest labels for NotificationStep and tests * Don't suggest labels in TestContactPointModal * [LabelsField] - refactor: get dataSourceName as a parameter * [LabelsField] - extract common code into reusable components * Display loading spinner while fetching rules * LabelsField - refactor Removing the suggest prop and the default dataSource 'grafana'. Instead, the component now relies on the dataSourceName param. If it's set it means we want to show suggestions so we fetch the labels, otherwise, if not set, we show the plain input texts without suggestions. * Add test for LabelsField without suggestions * Show custom labels for grafana managed alerts When the dataSourceName in the NotificationsStep component has a null value, we can assume it's because we're dealing with grafana managed alerts. In that case we set the correct value. * Fix tests after latest changes Since we removed the combobox from the TestContactPoints modal, tests had to be adjusted * Update texts * initialize all new added inputs with empty data --- .../alerting/unified/RuleEditor.test.tsx | 26 +- .../unified/components/AlertLabelDropdown.tsx | 38 +++ .../rule-editor/LabelsField.test.tsx | 110 ++++++ .../components/rule-editor/LabelsField.tsx | 317 ++++++++++++++---- .../rule-editor/NotificationsStep.tsx | 10 +- 5 files changed, 423 insertions(+), 78 deletions(-) create mode 100644 public/app/features/alerting/unified/components/AlertLabelDropdown.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/LabelsField.test.tsx diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index 3273427ce06..9854c4b403e 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -100,6 +100,8 @@ const ui = { }, }; +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); + describe('RuleEditor', () => { beforeEach(() => { jest.clearAllMocks(); @@ -175,10 +177,10 @@ describe('RuleEditor', () => { // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.type(ui.inputs.labelKey(0).get(), 'severity'); - await userEvent.type(ui.inputs.labelValue(0).get(), 'warn'); - await userEvent.type(ui.inputs.labelKey(1).get(), 'team'); - await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team'); + await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); // save and check what was sent to backend await userEvent.click(ui.buttons.save.get()); @@ -276,10 +278,10 @@ describe('RuleEditor', () => { // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.type(ui.inputs.labelKey(0).get(), 'severity'); - await userEvent.type(ui.inputs.labelValue(0).get(), 'warn'); - await userEvent.type(ui.inputs.labelKey(1).get(), 'team'); - await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team'); + await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); // save and check what was sent to backend await userEvent.click(ui.buttons.save.get()); @@ -370,8 +372,8 @@ describe('RuleEditor', () => { // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.type(ui.inputs.labelKey(1).get(), 'team'); - await userEvent.type(ui.inputs.labelValue(1).get(), 'the a-team'); + await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); // try to save, find out that recording rule name is invalid await userEvent.click(ui.buttons.save.get()); @@ -502,8 +504,8 @@ describe('RuleEditor', () => { await userEvent.type(ui.inputs.annotationValue(2).get(), 'value'); //add a label - await userEvent.type(ui.inputs.labelKey(2).get(), 'custom'); - await userEvent.type(ui.inputs.labelValue(2).get(), 'value'); + await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(2).get()), 'value{enter}'); // save and check what was sent to backend await userEvent.click(ui.buttons.save.get()); diff --git a/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx b/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx new file mode 100644 index 00000000000..21d369b5672 --- /dev/null +++ b/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { Select, Field } from '@grafana/ui'; + +export interface AlertLabelDropdownProps { + onChange: (newValue: SelectableValue) => void; + onOpenMenu?: () => void; + options: SelectableValue[]; + defaultValue?: SelectableValue; + type: 'key' | 'value'; +} + +const AlertLabelDropdown: FC = React.forwardRef( + function labelPicker({ onChange, options, defaultValue, type, onOpenMenu = () => {} }, ref) { + return ( +
+ + + + = + + + + +
+ + ); + })} + + + ); +}; + +const LabelsField: FC = ({ className, dataSourceName }) => { + const styles = useStyles2(getStyles); return (
- +
+ } + > + + + + <>
Labels
- {fields.map((field, index) => { - return ( -
-
- - - - = - - - -
-
- ); - })} - + {dataSourceName && } + {!dataSourceName && }
@@ -96,6 +280,9 @@ const LabelsField: FC = ({ className }) => { const getStyles = (theme: GrafanaTheme2) => { return { + icon: css` + margin-right: ${theme.spacing(0.5)}; + `, wrapper: css` margin-bottom: ${theme.spacing(4)}; `, diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx index 288675efdc0..8c9cedac09c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx @@ -1,9 +1,13 @@ import { css } from '@emotion/css'; import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Card, Link, useStyles2, useTheme2 } from '@grafana/ui'; +import { RuleFormValues } from '../../types/rule-form'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; + import LabelsField from './LabelsField'; import { RuleEditorSection } from './RuleEditorSection'; @@ -12,6 +16,10 @@ export const NotificationsStep = () => { const styles = useStyles2(getStyles); const theme = useTheme2(); + const { watch } = useFormContext(); + + const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME; + return ( { /> )}
- + Root route – default for all alerts From 0bd120e01b5b761c14c37dcd3fcaf42bc83443f8 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 11 Nov 2022 14:43:11 +0000 Subject: [PATCH 204/926] Navigation: fix page title spacing when there is no subtitle (#58654) * fix page title spacing when there is no subtitle * apply margin to whole page header --- public/app/core/components/PageNew/PageHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/core/components/PageNew/PageHeader.tsx b/public/app/core/components/PageNew/PageHeader.tsx index b0d3161bdbd..beaabaac0ce 100644 --- a/public/app/core/components/PageNew/PageHeader.tsx +++ b/public/app/core/components/PageNew/PageHeader.tsx @@ -72,13 +72,13 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', flexDirection: 'column', gap: theme.spacing(1), + marginBottom: theme.spacing(2), }), pageTitle: css({ display: 'flex', marginBottom: 0, }), subTitle: css({ - marginBottom: theme.spacing(2), position: 'relative', color: theme.colors.text.secondary, }), From 8edeb1aa2268c0c9666a739d6e0841aab98390b9 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Fri, 11 Nov 2022 17:49:46 +0300 Subject: [PATCH 205/926] Prometheus: Handle errors in buffered client (#58504) * Handle prometheus errors in buffered client * Handle prometheus warnings * Fix tests * Add unit test for warnings --- .../prometheus/buffered/time_series_query.go | 68 ++++++++-- .../buffered/time_series_query_test.go | 121 +++++++++++++----- 2 files changed, 145 insertions(+), 44 deletions(-) diff --git a/pkg/tsdb/prometheus/buffered/time_series_query.go b/pkg/tsdb/prometheus/buffered/time_series_query.go index 9e11c85cd92..fa1cdc107ae 100644 --- a/pkg/tsdb/prometheus/buffered/time_series_query.go +++ b/pkg/tsdb/prometheus/buffered/time_series_query.go @@ -3,6 +3,7 @@ package buffered import ( "context" "encoding/json" + "errors" "fmt" "math" "net/http" @@ -66,6 +67,11 @@ type Buffered struct { TimeInterval string } +type bufferedResponse struct { + Response interface{} + Warnings apiv1.Warnings +} + // New creates and object capable of executing and parsing a Prometheus queries. It's "buffered" because there is // another implementation capable of streaming parse the response. func New(roundTripper http.RoundTripper, tracer tracing.Tracer, settings backend.DataSourceInstanceSettings, plog log.Logger) (*Buffered, error) { @@ -143,7 +149,7 @@ func (b *Buffered) runQuery(ctx context.Context, query *PrometheusQuery) (backen logger := b.log.FromContext(ctx) // read trace-id and other info from the context logger.Debug("Sending query", "start", query.Start, "end", query.End, "step", query.Step, "query", query.Expr) - response := make(map[TimeSeriesQueryType]interface{}) + response := make(map[TimeSeriesQueryType]bufferedResponse) timeRange := apiv1.Range{ Step: query.Step, @@ -153,21 +159,39 @@ func (b *Buffered) runQuery(ctx context.Context, query *PrometheusQuery) (backen } if query.RangeQuery { - rangeResponse, _, err := b.client.QueryRange(ctx, query.Expr, timeRange) + rangeResponse, warnings, err := b.client.QueryRange(ctx, query.Expr, timeRange) if err != nil { + var promErr *apiv1.Error + if errors.As(err, &promErr) { + logger.Error("Range query failed", "query", query.Expr, "error", err, "detail", promErr.Detail) + return backend.DataResponse{Error: fmt.Errorf("%w: details: %s", err, promErr.Detail)}, nil + } + logger.Error("Range query failed", "query", query.Expr, "err", err) return backend.DataResponse{Error: err}, nil } - response[RangeQueryType] = rangeResponse + response[RangeQueryType] = bufferedResponse{ + Response: rangeResponse, + Warnings: warnings, + } } if query.InstantQuery { - instantResponse, _, err := b.client.Query(ctx, query.Expr, query.End) + instantResponse, warnings, err := b.client.Query(ctx, query.Expr, query.End) if err != nil { + var promErr *apiv1.Error + if errors.As(err, &promErr) { + logger.Error("Instant query failed", "query", query.Expr, "error", err, "detail", promErr.Detail) + return backend.DataResponse{Error: fmt.Errorf("%w: details: %s", err, promErr.Detail)}, nil + } + logger.Error("Instant query failed", "query", query.Expr, "err", err) return backend.DataResponse{Error: err}, nil } - response[InstantQueryType] = instantResponse + response[InstantQueryType] = bufferedResponse{ + Response: instantResponse, + Warnings: warnings, + } } // This is a special case @@ -177,7 +201,10 @@ func (b *Buffered) runQuery(ctx context.Context, query *PrometheusQuery) (backen if err != nil { logger.Error("Exemplar query failed", "query", query.Expr, "err", err) } else { - response[ExemplarQueryType] = exemplarResponse + response[ExemplarQueryType] = bufferedResponse{ + Response: exemplarResponse, + Warnings: nil, + } } } @@ -270,17 +297,17 @@ func (b *Buffered) parseTimeSeriesQuery(req *backend.QueryDataRequest) ([]*Prome return qs, nil } -func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *PrometheusQuery) (data.Frames, error) { +func parseTimeSeriesResponse(value map[TimeSeriesQueryType]bufferedResponse, query *PrometheusQuery) (data.Frames, error) { var ( frames = data.Frames{} nextFrames = data.Frames{} ) - for _, value := range value { + for _, val := range value { // Zero out the slice to prevent data corruption. nextFrames = nextFrames[:0] - switch v := value.(type) { + switch v := val.Response.(type) { case model.Matrix: nextFrames = matrixToDataFrames(v, query, nextFrames) case model.Vector: @@ -293,12 +320,35 @@ func parseTimeSeriesResponse(value map[TimeSeriesQueryType]interface{}, query *P return nil, fmt.Errorf("unexpected result type: %s query: %s", v, query.Expr) } + if len(val.Warnings) > 0 { + for _, frame := range nextFrames { + if frame.Meta == nil { + frame.Meta = &data.FrameMeta{} + } + frame.Meta.Notices = readWarnings(val.Warnings) + } + } + frames = append(frames, nextFrames...) } return frames, nil } +func readWarnings(warnings apiv1.Warnings) []data.Notice { + notices := []data.Notice{} + + for _, w := range warnings { + notice := data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: w, + } + notices = append(notices, notice) + } + + return notices +} + func calculatePrometheusInterval(model *QueryModel, timeInterval string, query backend.DataQuery, intervalCalculator intervalv2.Calculator) (time.Duration, error) { queryInterval := model.Interval diff --git a/pkg/tsdb/prometheus/buffered/time_series_query_test.go b/pkg/tsdb/prometheus/buffered/time_series_query_test.go index 8d78769edc4..fa81a32499d 100644 --- a/pkg/tsdb/prometheus/buffered/time_series_query_test.go +++ b/pkg/tsdb/prometheus/buffered/time_series_query_test.go @@ -598,7 +598,7 @@ func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) { func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { t.Run("exemplars response should be sampled and parsed normally", func(t *testing.T) { - value := make(map[TimeSeriesQueryType]interface{}) + value := make(map[TimeSeriesQueryType]bufferedResponse) exemplars := []apiv1.ExemplarQueryResult{ { SeriesLabels: p.LabelSet{ @@ -631,7 +631,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }, } - value[ExemplarQueryType] = exemplars + value[ExemplarQueryType] = bufferedResponse{ + Response: exemplars, + Warnings: nil, + } query := &PrometheusQuery{ LegendFormat: "legend {{app}}", } @@ -652,7 +655,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }) t.Run("exemplars response with inconsistent labels should marshal json ok", func(t *testing.T) { - value := make(map[TimeSeriesQueryType]interface{}) + value := make(map[TimeSeriesQueryType]bufferedResponse) exemplars := []apiv1.ExemplarQueryResult{ { SeriesLabels: p.LabelSet{ @@ -685,7 +688,10 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }, } - value[ExemplarQueryType] = exemplars + value[ExemplarQueryType] = bufferedResponse{ + Response: exemplars, + Warnings: nil, + } query := &PrometheusQuery{ LegendFormat: "legend {{app}}", } @@ -723,12 +729,15 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { {Value: 4, Timestamp: 4000}, {Value: 5, Timestamp: 5000}, } - value := make(map[TimeSeriesQueryType]interface{}) - value[RangeQueryType] = p.Matrix{ - &p.SampleStream{ - Metric: p.Metric{"app": "Application", "tag2": "tag2"}, - Values: values, + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: p.Matrix{ + &p.SampleStream{ + Metric: p.Metric{"app": "Application", "tag2": "tag2"}, + Values: values, + }, }, + Warnings: nil, } query := &PrometheusQuery{ LegendFormat: "legend {{app}}", @@ -760,12 +769,15 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { {Value: 1, Timestamp: 1000}, {Value: 4, Timestamp: 4000}, } - value := make(map[TimeSeriesQueryType]interface{}) - value[RangeQueryType] = p.Matrix{ - &p.SampleStream{ - Metric: p.Metric{"app": "Application", "tag2": "tag2"}, - Values: values, + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: p.Matrix{ + &p.SampleStream{ + Metric: p.Metric{"app": "Application", "tag2": "tag2"}, + Values: values, + }, }, + Warnings: nil, } query := &PrometheusQuery{ LegendFormat: "", @@ -791,12 +803,15 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { {Value: 1, Timestamp: 1000}, {Value: 4, Timestamp: 4000}, } - value := make(map[TimeSeriesQueryType]interface{}) - value[RangeQueryType] = p.Matrix{ - &p.SampleStream{ - Metric: p.Metric{"app": "Application", "tag2": "tag2"}, - Values: values, + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: p.Matrix{ + &p.SampleStream{ + Metric: p.Metric{"app": "Application", "tag2": "tag2"}, + Values: values, + }, }, + Warnings: nil, } query := &PrometheusQuery{ LegendFormat: "", @@ -820,14 +835,17 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }) t.Run("matrix response with NaN value should be changed to null", func(t *testing.T) { - value := make(map[TimeSeriesQueryType]interface{}) - value[RangeQueryType] = p.Matrix{ - &p.SampleStream{ - Metric: p.Metric{"app": "Application"}, - Values: []p.SamplePair{ - {Value: p.SampleValue(math.NaN()), Timestamp: 1000}, + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: p.Matrix{ + &p.SampleStream{ + Metric: p.Metric{"app": "Application"}, + Values: []p.SamplePair{ + {Value: p.SampleValue(math.NaN()), Timestamp: 1000}, + }, }, }, + Warnings: nil, } query := &PrometheusQuery{ LegendFormat: "", @@ -844,13 +862,16 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }) t.Run("vector response should be parsed normally", func(t *testing.T) { - value := make(map[TimeSeriesQueryType]interface{}) - value[RangeQueryType] = p.Vector{ - &p.Sample{ - Metric: p.Metric{"app": "Application", "tag2": "tag2"}, - Value: 1, - Timestamp: 123, + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: p.Vector{ + &p.Sample{ + Metric: p.Metric{"app": "Application", "tag2": "tag2"}, + Value: 1, + Timestamp: 123, + }, }, + Warnings: nil, } query := &PrometheusQuery{ LegendFormat: "legend {{app}}", @@ -876,10 +897,13 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }) t.Run("scalar response should be parsed normally", func(t *testing.T) { - value := make(map[TimeSeriesQueryType]interface{}) - value[RangeQueryType] = &p.Scalar{ - Value: 1, - Timestamp: 123, + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: &p.Scalar{ + Value: 1, + Timestamp: 123, + }, + Warnings: nil, } query := &PrometheusQuery{} @@ -899,6 +923,33 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { require.Equal(t, "UTC", testValue.(time.Time).Location().String()) require.Equal(t, int64(123), testValue.(time.Time).UnixMilli()) }) + + t.Run("warnings, if there is any, should be added to each frame", + func(t *testing.T) { + value := make(map[TimeSeriesQueryType]bufferedResponse) + value[RangeQueryType] = bufferedResponse{ + Response: &p.Scalar{ + Value: 1, + Timestamp: 123, + }, + Warnings: []string{"warning1", "warning2"}, + } + + query := &PrometheusQuery{} + res, err := parseTimeSeriesResponse(value, query) + require.NoError(t, err) + + require.Len(t, res, 1) + require.Equal(t, res[0].Name, "1") + require.Len(t, res[0].Fields, 2) + require.Len(t, res[0].Fields[0].Labels, 0) + require.Equal(t, res[0].Fields[0].Name, "Time") + require.Equal(t, res[0].Fields[1].Name, "Value") + require.Equal(t, res[0].Fields[1].Config.DisplayNameFromDS, "1") + + require.Equal(t, res[0].Meta.Notices[0].Text, "warning1") + require.Equal(t, res[0].Meta.Notices[1].Text, "warning2") + }) } func queryContext(json string, timeRange backend.TimeRange) *backend.QueryDataRequest { From 860e25df3cbe8d0c2d3f0f0b565355438fb2cc9f Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 11 Nov 2022 09:58:08 -0600 Subject: [PATCH 206/926] BarChart: add gdev panel for random threshold from query (#58580) --- .../barchart-thresholds-mappings.json | 124 +++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/devenv/dev-dashboards/panel-barchart/barchart-thresholds-mappings.json b/devenv/dev-dashboards/panel-barchart/barchart-thresholds-mappings.json index e482657d2dc..44ea090b30b 100644 --- a/devenv/dev-dashboards/panel-barchart/barchart-thresholds-mappings.json +++ b/devenv/dev-dashboards/panel-barchart/barchart-thresholds-mappings.json @@ -24,7 +24,6 @@ "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, - "id": 114, "links": [], "liveNow": false, "panels": [ @@ -684,6 +683,127 @@ "title": "override value mappings", "type": "barchart" }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 50, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "dashed" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 10, + "y": 9 + }, + "id": 14, + "maxDataPoints": 1, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "csvContent": "label,value\nx,3\na,10\nb,20\nc,30", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "hide": false, + "max": 30, + "min": 0.01, + "noise": 30, + "refId": "B", + "scenarioId": "random_walk", + "spread": 0, + "startValue": 1 + } + ], + "title": "threshold from random walk", + "transformations": [ + { + "id": "configFromData", + "options": { + "applyTo": { + "id": "byType", + "options": "number" + }, + "configRefId": "B", + "mappings": [ + { + "fieldName": "B-series", + "handlerKey": "threshold1" + } + ] + } + } + ], + "type": "barchart" + }, { "datasource": { "type": "testdata", @@ -1138,6 +1258,6 @@ "timezone": "", "title": "BarChart - Thresholds & Mappings", "uid": "2I2uMSB7z", - "version": 25, + "version": 32, "weekStart": "" } From 500cf16142375ba155df9afaae9a6414658ae021 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 11 Nov 2022 16:29:49 +0000 Subject: [PATCH 207/926] Chore: fix some types (#58662) chore: fix some types --- .betterer.results | 89 +++---------------- public/app/features/admin/UserProfile.tsx | 2 +- .../StandardAnnotationQueryEditor.tsx | 2 +- public/app/features/comments/CommentView.tsx | 2 +- .../dashboard/components/DashNav/DashNav.tsx | 2 +- .../DashboardPrompt/DashboardPrompt.tsx | 4 +- .../components/DashboardRow/DashboardRow.tsx | 2 +- .../DashboardSettings.test.tsx | 4 +- .../GeneralSettings.test.tsx | 4 +- .../DashboardSettings/GeneralSettings.tsx | 4 +- .../DashboardSettings/VersionsSettings.tsx | 2 +- .../LinksSettings/LinkSettingsEdit.tsx | 4 +- .../PanelEditor/AngularPanelOptions.tsx | 2 +- .../PanelEditor/getFieldOverrideElements.tsx | 2 +- .../PanelEditor/getVisualizationOptions.tsx | 2 +- .../components/PanelEditor/utils.test.ts | 15 +++- .../SaveDashboard/SaveDashboardDiff.tsx | 4 +- .../SharePublicDashboard.test.tsx | 2 +- .../TransformationEditor.tsx | 2 +- .../VersionHistory/HistorySrv.test.ts | 8 +- .../VersionHistoryComparison.tsx | 2 +- .../containers/DashboardPage.test.tsx | 4 +- .../dashboard/dashgrid/DashboardGrid.tsx | 6 +- .../PanelHeader/PanelHeaderMenuItem.tsx | 2 +- .../dashboard/services/DashboardSrv.ts | 2 +- .../services/PublicDashboardDataSource.ts | 4 +- 26 files changed, 61 insertions(+), 117 deletions(-) diff --git a/.betterer.results b/.betterer.results index 34cafb51e18..090f63ed372 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2780,9 +2780,6 @@ exports[`better eslint`] = { "public/app/features/admin/OrgRolePicker.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/admin/UserProfile.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/admin/ldap/LdapPage.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3106,9 +3103,6 @@ exports[`better eslint`] = { "public/app/features/annotations/components/AnnotationResultMapper.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/annotations/events_processing.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3177,9 +3171,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/comments/CommentView.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3238,32 +3229,14 @@ 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, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], "public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/dashboard/components/HelpWizard/randomizer.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -3280,12 +3253,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] @@ -3304,8 +3271,7 @@ exports[`better eslint`] = { ], "public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3313,8 +3279,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "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"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/features/dashboard/components/PanelEditor/state/actions.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3336,10 +3301,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/dashboard/components/PanelEditor/utils.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/dashboard/components/PanelEditor/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -3351,10 +3312,6 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/dashboard/components/SaveDashboard/SaveDashboardErrorProxy.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3391,9 +3348,6 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/ShareModal/ShareModal.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3408,8 +3362,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3419,16 +3372,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], - "public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/dashboard/components/VersionHistory/__mocks__/dashboardHistoryMocks.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -3450,8 +3393,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "public/app/features/dashboard/containers/DashboardPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -3473,10 +3415,7 @@ exports[`better eslint`] = { ], "public/app/features/dashboard/dashgrid/DashboardGrid.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/features/dashboard/dashgrid/DashboardPanel.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -3484,9 +3423,6 @@ exports[`better eslint`] = { "public/app/features/dashboard/dashgrid/LazyLoader.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -3507,13 +3443,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], "public/app/features/dashboard/services/DashboardSrv.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard/services/PublicDashboardDataSource.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard/services/TimeSrv.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/public/app/features/admin/UserProfile.tsx b/public/app/features/admin/UserProfile.tsx index 1d1e3f6d43b..11e5d5e6e63 100644 --- a/public/app/features/admin/UserProfile.tsx +++ b/public/app/features/admin/UserProfile.tsx @@ -295,7 +295,7 @@ export class UserProfileRow extends PureComponent { - state = {} as State; + state: State = {}; componentDidMount() { this.verifyDataSource(); diff --git a/public/app/features/comments/CommentView.tsx b/public/app/features/comments/CommentView.tsx index a041050ae1b..e3f6defac89 100644 --- a/public/app/features/comments/CommentView.tsx +++ b/public/app/features/comments/CommentView.tsx @@ -29,7 +29,7 @@ export const CommentView = ({ comments, packetCounter, addComment }: Props) => { }, [packetCounter]); const onUpdateComment = (event: FormEvent) => { - const element = event.target as HTMLInputElement; + const element = event.currentTarget; setComment(element.value); }; diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 6363840274d..2ada1fcc28d 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -87,7 +87,7 @@ export const DashNav = React.memo((props) => { const dashboardSrv = getDashboardSrv(); const { dashboard, setStarred } = props; - dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then((newState) => { + dashboardSrv.starDashboard(dashboard.id, Boolean(dashboard.meta.isStarred)).then((newState) => { setStarred({ id: dashboard.uid, title: dashboard.title, url: dashboard.meta.url ?? '', isStarred: newState }); dashboard.meta.isStarred = newState; forceUpdate(); diff --git a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx index ad51ced0477..ab14b47bc27 100644 --- a/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx +++ b/public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.tsx @@ -201,8 +201,8 @@ export function hasChanges(current: DashboardModel, original: unknown) { const currentClean = cleanDashboardFromIgnoredChanges(current.getSaveModelClone()); const originalClean = cleanDashboardFromIgnoredChanges(original); - const currentTimepicker: any = find((currentClean as any).nav, { type: 'timepicker' }); - const originalTimepicker: any = find((originalClean as any).nav, { type: 'timepicker' }); + const currentTimepicker = find((currentClean as any).nav, { type: 'timepicker' }); + const originalTimepicker = find((originalClean as any).nav, { type: 'timepicker' }); if (currentTimepicker && originalTimepicker) { currentTimepicker.now = originalTimepicker.now; diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx index de1e29cc8ed..0467ae662d7 100644 --- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx @@ -17,7 +17,7 @@ export interface DashboardRowProps { dashboard: DashboardModel; } -export class DashboardRow extends React.Component { +export class DashboardRow extends React.Component { sub?: Unsubscribable; componentDidMount() { diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx index a7cb7cf6b9e..81558d8374f 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx @@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { NavModel, NavModelItem } from '@grafana/data'; -import { setBackendSrv } from '@grafana/runtime'; +import { BackendSrv, setBackendSrv } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { configureStore } from 'app/store/configureStore'; @@ -23,7 +23,7 @@ jest.mock('@grafana/runtime', () => ({ setBackendSrv({ get: jest.fn().mockResolvedValue([]), -} as any); +} as unknown as BackendSrv); describe('DashboardSettings', () => { it('pressing escape navigates away correctly', async () => { diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx index b13be0bdf34..e8c4aba49d1 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx @@ -7,7 +7,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { byRole } from 'testing-library-selector'; import { selectors } from '@grafana/e2e-selectors'; -import { setBackendSrv } from '@grafana/runtime'; +import { BackendSrv, setBackendSrv } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { DashboardModel } from '../../state'; @@ -16,7 +16,7 @@ import { GeneralSettingsUnconnected as GeneralSettings, Props } from './GeneralS setBackendSrv({ get: jest.fn().mockResolvedValue([]), -} as any); +} as unknown as BackendSrv); const setupTestContext = (options: Partial) => { const defaults: Props = { diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index da28d446b72..789b60b2ffc 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -37,7 +37,9 @@ export function GeneralSettingsUnconnected({ }; const onBlur = (event: React.FocusEvent) => { - dashboard[event.currentTarget.name as 'title' | 'description'] = event.currentTarget.value; + if (event.currentTarget.name === 'title' || event.currentTarget.name === 'description') { + dashboard[event.currentTarget.name] = event.currentTarget.value; + } }; const onTooltipChange = (graphTooltip: number) => { diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index 0db6b274558..5068bc95b77 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -21,7 +21,7 @@ type State = { isAppending: boolean; versions: DecoratedRevisionModel[]; viewMode: 'list' | 'compare'; - diffData: { lhs: any; rhs: any }; + diffData: { lhs: unknown; rhs: unknown }; newInfo?: DecoratedRevisionModel; baseInfo?: DecoratedRevisionModel; isNewLatest: boolean; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx index c58fbc7d679..046395e134b 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -5,7 +5,7 @@ import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button, import { DashboardLink, DashboardModel } from '../../state/DashboardModel'; -export const newLink = { +export const newLink: DashboardLink = { icon: 'external link', title: 'New link', tooltip: '', @@ -16,7 +16,7 @@ export const newLink = { targetBlank: false, keepTime: false, includeVars: false, -} as DashboardLink; +}; const linkTypeOptions = [ { value: 'dashboards', label: 'Dashboards' }, diff --git a/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx b/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx index b625700c025..df62f2fcb04 100644 --- a/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/AngularPanelOptions.tsx @@ -110,7 +110,7 @@ export class AngularPanelOptionsUnconnected extends PureComponent { toggleOptionGroup: (index: number) => { const tab = panelCtrl.editorTabs[index]; tab.isOpen = !tab.isOpen; - saveSectionOpenState(tab.title, tab.isOpen as boolean); + saveSectionOpenState(tab.title, Boolean(tab.isOpen)); }, }; diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx index 9ca84fdf48a..0da94a6fafd 100644 --- a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -33,7 +33,7 @@ export function getFieldOverrideCategories( return []; } - const onOverrideChange = (index: number, override: any) => { + const onOverrideChange = (index: number, override: ConfigOverrideRule) => { let overrides = cloneDeep(currentFieldConfig.overrides); overrides[index] = override; props.onFieldConfigsChange({ ...currentFieldConfig, overrides }); diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx index a3fa3263e73..5301e05c07f 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx @@ -196,7 +196,7 @@ export function fillOptionsPaneItems( return ( { + onChange={(value) => { access.onChange(pluginOption.path, value); }} item={pluginOption} diff --git a/public/app/features/dashboard/components/PanelEditor/utils.test.ts b/public/app/features/dashboard/components/PanelEditor/utils.test.ts index b2efaa7aa7f..42c9485c3d6 100644 --- a/public/app/features/dashboard/components/PanelEditor/utils.test.ts +++ b/public/app/features/dashboard/components/PanelEditor/utils.test.ts @@ -1,4 +1,10 @@ -import { FieldConfig, FieldConfigSource, PanelPlugin, standardFieldConfigEditorRegistry } from '@grafana/data'; +import { + FieldConfig, + FieldConfigSource, + PanelPlugin, + standardFieldConfigEditorRegistry, + ThresholdsMode, +} from '@grafana/data'; import { setOptionImmutably, supportsDataQuery, updateDefaultFieldConfigValue } from './utils'; @@ -8,10 +14,13 @@ describe('standardFieldConfigEditorRegistry', () => { min: 10, max: 10, decimals: 10, - thresholds: {} as any, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [], + }, noValue: 'no value', unit: 'km/s', - links: {} as any, + links: [], }; it('make sure all fields have a valid name', () => { diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx index d1b997f8882..1b3746d87c5 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx @@ -10,8 +10,8 @@ import { DiffViewer } from '../VersionHistory/DiffViewer'; import { Diffs } from '../VersionHistory/utils'; interface SaveDashboardDiffProps { - oldValue?: any; - newValue?: any; + oldValue?: unknown; + newValue?: unknown; // calculated by parent so we can see summary in tabs diff?: Diffs; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index 454e52b6f29..f26c0a30e93 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -62,7 +62,7 @@ beforeAll(() => { ], }, ], - } as any; + } as BootData; server.listen({ onUnhandledRequest: 'bypass' }); }); diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx index f1cb7042a7f..c6644031ade 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx @@ -55,7 +55,7 @@ export const TransformationEditor = ({ React.createElement(uiConfig.editor, { options: { ...uiConfig.transformation.defaultOptions, ...config.transformation.options }, input, - onChange: (opts: any) => { + onChange: (opts) => { onChange(index, { id: config.transformation.id, options: opts }); }, }), diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts index be6d274abbd..ced192487d8 100644 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts @@ -38,19 +38,19 @@ describe('historySrv', () => { getMock.mockImplementation(() => Promise.resolve(versionsResponse)); historySrv = new HistorySrv(); - return historySrv.getHistoryList(dash, historyListOpts).then((versions: any) => { + return historySrv.getHistoryList(dash, historyListOpts).then((versions) => { expect(versions).toEqual(versionsResponse); }); }); it('should return an empty array when not given an id', () => { - return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions: any) => { + return historySrv.getHistoryList(emptyDash, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); it('should return an empty array when not given a dashboard', () => { - return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions: any) => { + return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); @@ -61,7 +61,7 @@ describe('historySrv', () => { const version = 6; postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); historySrv = new HistorySrv(); - return historySrv.restoreDashboard(dash, version).then((response: any) => { + return historySrv.restoreDashboard(dash, version).then((response) => { expect(response).toEqual(restoreResponse(version)); }); }); diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx index 2a106e45b3e..db85c1bf1fe 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx @@ -15,7 +15,7 @@ type DiffViewProps = { isNewLatest: boolean; newInfo: DecoratedRevisionModel; baseInfo: DecoratedRevisionModel; - diffData: { lhs: any; rhs: any }; + diffData: { lhs: unknown; rhs: unknown }; }; export const VersionHistoryComparison: React.FC = ({ baseInfo, newInfo, diffData, isNewLatest }) => { diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 07cdfb4dca9..be37870e6a3 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -17,7 +17,7 @@ import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types'; import { configureStore } from '../../../store/configureStore'; import { Props as LazyLoaderProps } from '../dashgrid/LazyLoader'; -import { setDashboardSrv } from '../services/DashboardSrv'; +import { DashboardSrv, setDashboardSrv } from '../services/DashboardSrv'; import { DashboardModel } from '../state'; import { Props, UnthemedDashboardPage } from './DashboardPage'; @@ -217,7 +217,7 @@ describe('DashboardPage', () => { }); setDashboardSrv({ getCurrent: () => getTestDashboard(), - } as any); + } as DashboardSrv); ctx.mount({ dashboard: getTestDashboard(), queryParams: { viewPanel: '1' }, diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index b24841fe679..74f11cfea55 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -52,7 +52,7 @@ export class DashboardGrid extends PureComponent { } buildLayout() { - const layout = []; + const layout: ReactGridLayout.Layout[] = []; this.panelMap = {}; for (const panel of this.props.dashboard.panels) { @@ -66,7 +66,7 @@ export class DashboardGrid extends PureComponent { continue; } - const panelPos: any = { + const panelPos: ReactGridLayout.Layout = { i: panel.key, x: panel.gridPos.x, y: panel.gridPos.y, @@ -176,7 +176,7 @@ export class DashboardGrid extends PureComponent { return panelElements; } - renderPanel(panel: PanelModel, width: any, height: any) { + renderPanel(panel: PanelModel, width: number, height: number) { if (panel.type === 'row') { return ; } diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx index 23e82c1cc88..05cf46d3510 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx @@ -6,7 +6,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Icon, toIconName, useStyles2 } from '@grafana/ui'; interface Props { - children?: any; + children?: React.ReactNode; } export const PanelHeaderMenuItem: FC = (props) => { diff --git a/public/app/features/dashboard/services/DashboardSrv.ts b/public/app/features/dashboard/services/DashboardSrv.ts index 0a0ae013172..0b4eb8d0f07 100644 --- a/public/app/features/dashboard/services/DashboardSrv.ts +++ b/public/app/features/dashboard/services/DashboardSrv.ts @@ -90,7 +90,7 @@ export class DashboardSrv { ); } - starDashboard(dashboardId: string, isStarred: any) { + starDashboard(dashboardId: string, isStarred: boolean) { const backendSrv = getBackendSrv(); let promise; diff --git a/public/app/features/dashboard/services/PublicDashboardDataSource.ts b/public/app/features/dashboard/services/PublicDashboardDataSource.ts index c57d8b7effe..f9a09e89515 100644 --- a/public/app/features/dashboard/services/PublicDashboardDataSource.ts +++ b/public/app/features/dashboard/services/PublicDashboardDataSource.ts @@ -100,7 +100,7 @@ export class PublicDashboardDataSource extends DataSourceApi({ @@ -135,7 +135,7 @@ export class PublicDashboardDataSource extends DataSourceApi { + testDatasource(): Promise { return Promise.resolve(null); } } From 1c5039085bdf9252d0aa1666675f5313589c1703 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Fri, 11 Nov 2022 19:53:12 +0300 Subject: [PATCH 208/926] Prometheus: Make Prometheus streaming parser as default client (#58365) * Introduce a new feature flag for prometheus buffered client * Use querydata client as default and put buffered client behind the feature flag * Remove prometheusStreamingJSONParser feature flag as it is not needed anymore * Update tests * Fix unit tests * Update feature flag description --- .../grafana-data/src/types/featureToggles.gen.ts | 2 +- pkg/services/featuremgmt/registry.go | 6 +++--- pkg/services/featuremgmt/toggles_gen.go | 6 +++--- pkg/tests/api/prometheus/prometheus_test.go | 13 ++++++------- pkg/tsdb/prometheus/prometheus.go | 6 +++--- pkg/tsdb/prometheus/querydata/request_test.go | 3 ++- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b38759798f8..c115b43a80a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -52,7 +52,7 @@ export interface FeatureToggles { cloudWatchDynamicLabels?: boolean; datasourceQueryMultiStatus?: boolean; traceToMetrics?: boolean; - prometheusStreamingJSONParser?: boolean; + prometheusBufferedClient?: boolean; newDBLibrary?: boolean; validateDashboardsOnSave?: boolean; autoMigrateGraphPanels?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index dbd3223d1f0..39f308edf15 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -210,9 +210,9 @@ var ( FrontendOnly: true, }, { - Name: "prometheusStreamingJSONParser", - Description: "Enable streaming JSON parser for Prometheus datasource", - State: FeatureStateBeta, + Name: "prometheusBufferedClient", + Description: "Enable buffered (old) client for Prometheus datasource as default instead of streaming JSON parser client (new)", + State: FeatureStateStable, }, { Name: "newDBLibrary", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 6a952ba0e75..325fa48559a 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -151,9 +151,9 @@ const ( // Enable trace to metrics links FlagTraceToMetrics = "traceToMetrics" - // FlagPrometheusStreamingJSONParser - // Enable streaming JSON parser for Prometheus datasource - FlagPrometheusStreamingJSONParser = "prometheusStreamingJSONParser" + // FlagPrometheusBufferedClient + // Enable buffered (old) client for Prometheus datasource as default instead of streaming JSON parser client (new) + FlagPrometheusBufferedClient = "prometheusBufferedClient" // FlagNewDBLibrary // Use jmoiron/sqlx rather than xorm for a few backend services diff --git a/pkg/tests/api/prometheus/prometheus_test.go b/pkg/tests/api/prometheus/prometheus_test.go index b86835ebf9a..391ad6ec73a 100644 --- a/pkg/tests/api/prometheus/prometheus_test.go +++ b/pkg/tests/api/prometheus/prometheus_test.go @@ -79,8 +79,8 @@ func TestIntegrationPrometheusBuffered(t *testing.T) { }) buf1 := &bytes.Buffer{} err = json.NewEncoder(buf1).Encode(dtos.MetricRequest{ - From: "now-1h", - To: "now", + From: "1668078080000", + To: "1668081680000", Queries: []*simplejson.Json{query}, }) require.NoError(t, err) @@ -88,7 +88,7 @@ func TestIntegrationPrometheusBuffered(t *testing.T) { // nolint:gosec resp, err := http.Post(u, "application/json", buf1) require.NoError(t, err) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) t.Cleanup(func() { err := resp.Body.Close() require.NoError(t, err) @@ -97,7 +97,8 @@ func TestIntegrationPrometheusBuffered(t *testing.T) { require.NoError(t, err) require.NotNil(t, outgoingRequest) - require.Equal(t, "/api/v1/query_range?q1=1&q2=2", outgoingRequest.URL.String()) + require.Equal(t, "/api/v1/query_range?end=1668081660&q1=1&q2=2&query=up&start=1668078060&step=30", + outgoingRequest.URL.String()) require.Equal(t, "custom-header-value", outgoingRequest.Header.Get("X-CUSTOM-HEADER")) username, pwd, ok := outgoingRequest.BasicAuth() require.True(t, ok) @@ -110,9 +111,7 @@ func TestIntegrationPrometheusClient(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - EnableFeatureToggles: []string{"prometheusStreamingJSONParser"}, - }) + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{}) grafanaListeningAddr, testEnv := testinfra.StartGrafanaEnv(t, dir, path) ctx := context.Background() diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 4f9d592dcae..ae8667225e3 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -93,11 +93,11 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return nil, err } - if s.features.IsEnabled(featuremgmt.FlagPrometheusStreamingJSONParser) || s.features.IsEnabled(featuremgmt.FlagPrometheusWideSeries) { - return i.queryData.Execute(ctx, req) + if s.features.IsEnabled(featuremgmt.FlagPrometheusBufferedClient) { + return i.buffered.ExecuteTimeSeriesQuery(ctx, req) } - return i.buffered.ExecuteTimeSeriesQuery(ctx, req) + return i.queryData.Execute(ctx, req) } func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { diff --git a/pkg/tsdb/prometheus/querydata/request_test.go b/pkg/tsdb/prometheus/querydata/request_test.go index abc34dc608d..638b84330fc 100644 --- a/pkg/tsdb/prometheus/querydata/request_test.go +++ b/pkg/tsdb/prometheus/querydata/request_test.go @@ -415,7 +415,8 @@ func setup(wideFrames bool) (*testContext, error) { JSONData: json.RawMessage(`{"timeInterval": "15s"}`), } - features := &fakeFeatureToggles{flags: map[string]bool{"prometheusStreamingJSONParser": true, "prometheusWideSeries": wideFrames}} + features := &fakeFeatureToggles{flags: map[string]bool{"prometheusBufferedClient": false, + "prometheusWideSeries": wideFrames}} opts, err := client.CreateTransportOptions(settings, &setting.Cfg{}, &logtest.Fake{}) if err != nil { From d748979048d16ff9cb4a3c116f9f3b2b61ca002e Mon Sep 17 00:00:00 2001 From: gotjosh Date: Fri, 11 Nov 2022 17:27:13 +0000 Subject: [PATCH 209/926] Alerting: Implement the Webex notifier (#58480) * Alerting: Implement the Webex notifier Closes https://github.com/grafana/grafana/issues/11750 Signed-off-by: gotjosh --- .../administration/provisioning/index.md | 9 + .../fundamentals/contact-points/index.md | 1 + .../images-in-notifications.md | 1 + go.mod | 2 +- .../ngalert/notifier/channels/factory.go | 4 +- .../ngalert/notifier/channels/webex.go | 211 ++++++++++++++++++ .../ngalert/notifier/channels/webex_test.go | 148 ++++++++++++ .../channels_config/available_channels.go | 44 ++++ 8 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 pkg/services/ngalert/notifier/channels/webex.go create mode 100644 pkg/services/ngalert/notifier/channels/webex_test.go diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 5a36e439eb2..dcda10846aa 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -632,6 +632,15 @@ The following sections detail the supported settings and secure settings for eac | ---- | | url | +#### Alert notification `Cisco Webex Teams` + +| Name | Secure setting | +| --------- | -------------- | +| message | | +| room_id | | +| api_url | | +| bot_token | yes | + ## Grafana Enterprise Grafana Enterprise supports provisioning for the following resources: diff --git a/docs/sources/alerting/fundamentals/contact-points/index.md b/docs/sources/alerting/fundamentals/contact-points/index.md index 2c6d3eb2372..caaf4235969 100644 --- a/docs/sources/alerting/fundamentals/contact-points/index.md +++ b/docs/sources/alerting/fundamentals/contact-points/index.md @@ -44,6 +44,7 @@ The following table lists the contact point types supported by Grafana. | [Threema](https://threema.ch/) | `threema` | Supported | N/A | | [VictorOps](https://help.victorops.com/) | `victorops` | Supported | Supported | | [Webhook](#webhook) | `webhook` | Supported | Supported ([different format](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config)) | +| [Cisco Webex Teams](#webex) | `webex` | Supported | Supported | | [WeCom](#wecom) | `wecom` | Supported | N/A | | [Zenduty](https://www.zenduty.com/) | `webhook` | Supported | N/A | diff --git a/docs/sources/alerting/manage-notifications/images-in-notifications.md b/docs/sources/alerting/manage-notifications/images-in-notifications.md index aa5ee8d8ed5..e2dc58e7778 100644 --- a/docs/sources/alerting/manage-notifications/images-in-notifications.md +++ b/docs/sources/alerting/manage-notifications/images-in-notifications.md @@ -79,6 +79,7 @@ Images in notifications are supported in the following notifiers and additional | Threema | No | No | | VictorOps | No | No | | Webhook | No | Yes | +| Cisco Webex Teams | No | Yes | Include images from URL refers to using the external image store. diff --git a/go.mod b/go.mod index 401f303c286..1630089a699 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/go-sql-driver/mysql v1.6.0 github.com/go-stack/stack v1.8.1 github.com/gobwas/glob v0.2.3 - github.com/gofrs/uuid v4.3.0+incompatible // indirect + github.com/gofrs/uuid v4.3.0+incompatible github.com/gogo/protobuf v1.3.2 github.com/golang/mock v1.6.0 github.com/golang/snappy v0.0.4 diff --git a/pkg/services/ngalert/notifier/channels/factory.go b/pkg/services/ngalert/notifier/channels/factory.go index 0a8d4c6a18b..f8eb66286e2 100644 --- a/pkg/services/ngalert/notifier/channels/factory.go +++ b/pkg/services/ngalert/notifier/channels/factory.go @@ -5,9 +5,10 @@ import ( "errors" "strings" + "github.com/prometheus/alertmanager/template" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/notifications" - "github.com/prometheus/alertmanager/template" ) type FactoryConfig struct { @@ -65,6 +66,7 @@ var receiverFactories = map[string]func(FactoryConfig) (NotificationChannel, err "victorops": VictorOpsFactory, "webhook": WebHookFactory, "wecom": WeComFactory, + "webex": WebexFactory, } func Factory(receiverType string) (func(FactoryConfig) (NotificationChannel, error), bool) { diff --git a/pkg/services/ngalert/notifier/channels/webex.go b/pkg/services/ngalert/notifier/channels/webex.go new file mode 100644 index 00000000000..63121e2f7cb --- /dev/null +++ b/pkg/services/ngalert/notifier/channels/webex.go @@ -0,0 +1,211 @@ +package channels + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/notifications" +) + +const webexAPIURL = "https://webexapis.com/v1/messages" + +// WebexNotifier is responsible for sending alert notifications as webex messages. +type WebexNotifier struct { + *Base + ns notifications.WebhookSender + log log.Logger + images ImageStore + tmpl *template.Template + orgID int64 + settings *webexSettings +} + +// PLEASE do not touch these settings without taking a look at what we support as part of +// https://github.com/prometheus/alertmanager/blob/main/notify/webex/webex.go +// Currently, the Alerting team is unifying channels and (upstream) receivers - any discrepancy is detrimental to that. +type webexSettings struct { + Message string `json:"message,omitempty" yaml:"message,omitempty"` + RoomID string `json:"room_id,omitempty" yaml:"room_id,omitempty"` + APIURL string `json:"api_url,omitempty" yaml:"api_url,omitempty"` + Token string `json:"bot_token" yaml:"bot_token"` +} + +func buildWebexSettings(factoryConfig FactoryConfig) (*webexSettings, error) { + settings := &webexSettings{} + err := factoryConfig.Config.unmarshalSettings(&settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + if settings.APIURL == "" { + settings.APIURL = webexAPIURL + } + + if settings.Message == "" { + settings.Message = DefaultMessageEmbed + } + + settings.Token = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "bot_token", settings.Token) + + u, err := url.Parse(settings.APIURL) + if err != nil { + return nil, fmt.Errorf("invalid URL %q", settings.APIURL) + } + settings.APIURL = u.String() + + return settings, err +} + +func WebexFactory(fc FactoryConfig) (NotificationChannel, error) { + notifier, err := buildWebexNotifier(fc) + if err != nil { + return nil, receiverInitError{ + Reason: err.Error(), + Cfg: *fc.Config, + } + } + return notifier, nil +} + +// buildWebexSettings is the constructor for the Webex notifier. +func buildWebexNotifier(factoryConfig FactoryConfig) (*WebexNotifier, error) { + settings, err := buildWebexSettings(factoryConfig) + if err != nil { + return nil, err + } + + logger := log.New("alerting.notifier.webex") + + return &WebexNotifier{ + Base: NewBase(&models.AlertNotification{ + Uid: factoryConfig.Config.UID, + Name: factoryConfig.Config.Name, + Type: factoryConfig.Config.Type, + DisableResolveMessage: factoryConfig.Config.DisableResolveMessage, + Settings: factoryConfig.Config.Settings, + }), + orgID: factoryConfig.Config.OrgID, + log: logger, + ns: factoryConfig.NotificationService, + images: factoryConfig.ImageStore, + tmpl: factoryConfig.Template, + settings: settings, + }, nil +} + +// WebexMessage defines the JSON object to send to Webex endpoints. +type WebexMessage struct { + RoomID string `json:"roomId,omitempty"` + Message string `json:"markdown"` + Files []string `json:"files,omitempty"` +} + +// Notify implements the Notifier interface. +func (wn *WebexNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + var tmplErr error + tmpl, data := TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) + + message, truncated := TruncateInBytes(tmpl(wn.settings.Message), 4096) + if truncated { + wn.log.Warn("Webex message too long, truncating message", "OriginalMessage", wn.settings.Message) + } + + if tmplErr != nil { + wn.log.Warn("Failed to template webex message", "Error", tmplErr.Error()) + tmplErr = nil + } + + msg := &WebexMessage{ + RoomID: wn.settings.RoomID, + Message: message, + Files: []string{}, + } + + // Augment our Alert data with ImageURLs if available. + _ = withStoredImages(ctx, wn.log, wn.images, func(index int, image ngmodels.Image) error { + // Cisco Webex only supports a single image per request: https://developer.webex.com/docs/basics#message-attachments + if image.HasURL() { + data.Alerts[index].ImageURL = image.URL + msg.Files = append(msg.Files, image.URL) + return ErrImagesDone + } + + return nil + }, as...) + + body, err := json.Marshal(msg) + if err != nil { + return false, err + } + + parsedURL := tmpl(wn.settings.APIURL) + if tmplErr != nil { + return false, tmplErr + } + + cmd := &models.SendWebhookSync{ + Url: parsedURL, + Body: string(body), + HttpMethod: http.MethodPost, + } + + if wn.settings.Token != "" { + headers := make(map[string]string) + headers["Authorization"] = fmt.Sprintf("Bearer %s", wn.settings.Token) + cmd.HttpHeader = headers + } + + if err := wn.ns.SendWebhookSync(ctx, cmd); err != nil { + return false, err + } + + return true, nil +} + +func (wn *WebexNotifier) SendResolved() bool { + return !wn.GetDisableResolveMessage() +} + +// Copied from https://github.com/prometheus/alertmanager/blob/main/notify/util.go, please remove once we're on-par with upstream. +// truncationMarker is the character used to represent a truncation. +const truncationMarker = "…" + +// TruncateInBytes truncates a string to fit the given size in Bytes. +func TruncateInBytes(s string, n int) (string, bool) { + // First, measure the string the w/o a to-rune conversion. + if len(s) <= n { + return s, false + } + + // The truncationMarker itself is 3 bytes, we can't return any part of the string when it's less than 3. + if n <= 3 { + switch n { + case 3: + return truncationMarker, true + default: + return strings.Repeat(".", n), true + } + } + + // Now, to ensure we don't butcher the string we need to remove using runes. + r := []rune(s) + truncationTarget := n - 3 + + // Next, let's truncate the runes to the lower possible number. + truncatedRunes := r[:truncationTarget] + for len(string(truncatedRunes)) > truncationTarget { + truncatedRunes = r[:len(truncatedRunes)-1] + } + + return string(truncatedRunes) + truncationMarker, true +} diff --git a/pkg/services/ngalert/notifier/channels/webex_test.go b/pkg/services/ngalert/notifier/channels/webex_test.go new file mode 100644 index 00000000000..6f456f4975c --- /dev/null +++ b/pkg/services/ngalert/notifier/channels/webex_test.go @@ -0,0 +1,148 @@ +package channels + +import ( + "context" + "fmt" + "net/url" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" + + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" +) + +func TestWebexNotifier(t *testing.T) { + tmpl := templateForTests(t) + images := newFakeImageStoreWithFile(t, 2) + + externalURL, err := url.Parse("http://localhost") + require.NoError(t, err) + tmpl.ExternalURL = externalURL + + cases := []struct { + name string + settings string + alerts []*types.Alert + expHeaders map[string]string + expMsg string + expInitError string + expMsgError error + }{ + { + name: "A single alert with default template", + settings: `{ + "bot_token": "abcdefgh0123456789", + "room_id": "someid" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, + GeneratorURL: "a URL", + }, + }, + }, + expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, + expMsg: `{"roomId":"someid","markdown":"**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n","files":["https://www.example.com/test-image-1"]}`, + expMsgError: nil, + }, + { + name: "Multiple alerts with custom template", + settings: `{ + "bot_token": "abcdefgh0123456789", + "room_id": "someid", + "message": "__Custom Firing__\n{{len .Alerts.Firing}} Firing\n{{ template \"__text_alert_list\" .Alerts.Firing }}" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, + GeneratorURL: "a URL", + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, + }, + }, + }, + expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, + expMsg: `{"roomId":"someid","markdown":"__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval2\n","files":["https://www.example.com/test-image-1"]}`, + expMsgError: nil, + }, + { + name: "Truncate long message", + settings: `{ + "bot_token": "abcdefgh0123456789", + "room_id": "someid", + "message": "{{ .CommonLabels.alertname }}" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": model.LabelValue(strings.Repeat("1", 4097))}, + }, + }, + }, + expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, + expMsg: fmt.Sprintf(`{"roomId":"someid","markdown":"%s…"}`, strings.Repeat("1", 4093)), + expMsgError: nil, + }, + { + name: "Error in initing", + settings: `{ "api_url": "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require" }`, + expInitError: `invalid URL "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require"`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + settingsJSON, err := simplejson.NewJson([]byte(c.settings)) + require.NoError(t, err) + secureSettings := make(map[string][]byte) + + secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + decryptFn := secretsService.GetDecryptedValue + notificationService := mockNotificationService() + + fc := FactoryConfig{ + Config: &NotificationChannelConfig{ + Name: "webex_tests", + Type: "webex", + Settings: settingsJSON, + SecureSettings: secureSettings, + }, + ImageStore: images, + NotificationService: notificationService, + DecryptFunc: decryptFn, + Template: tmpl, + } + + n, err := buildWebexNotifier(fc) + if c.expInitError != "" { + require.Error(t, err) + require.Equal(t, c.expInitError, err.Error()) + return + } + require.NoError(t, err) + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + ok, err := n.Notify(ctx, c.alerts...) + require.NoError(t, err) + require.True(t, ok) + + require.NoError(t, err) + require.Equal(t, c.expHeaders, notificationService.Webhook.HttpHeader) + require.JSONEq(t, c.expMsg, notificationService.Webhook.Body) + }) + } +} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 64143f8ab03..49e4c01b93b 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -1101,5 +1101,49 @@ func GetAvailableNotifiers() []*NotifierPlugin { }, }, }, + { + Type: "webex", + Name: "Cisco Webex Teams", + Description: "Sends notifications to Cisco Webex Teams", + Heading: "Webex settings", + Info: "Notifications can be configured for any Cisco Webex Teams", + Options: []NotifierOption{ + { + Label: "Cisco Webex API URL", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "https://api.ciscospark.com/v1/messages", + Description: "API endpoint at which we'll send webhooks to.", + PropertyName: "api_url", + }, + { + Label: "Room ID", + Description: "The room ID to send messages to.", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: "GMtOWY0ZGJkNzMyMGFl", + PropertyName: "room_id", + Required: true, + }, + { + Label: "Bot Token", + Description: "Non-expiring access token of the bot that will post messages on our behalf.", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: `GMtOWY0ZGJkNzMyMGFl-12535454-123213`, + PropertyName: "bot_token", + Secure: true, + Required: true, + }, + { + Label: "Message Template", + Description: "Message template to use. Markdown is supported.", + Element: ElementTypeInput, + InputType: InputTypeText, + Placeholder: `{{ template "default.message" . }}`, + PropertyName: "message", + }, + }, + }, } } From 79142340e085aa1aa1e02ae77bd59b481c2e5f96 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Fri, 11 Nov 2022 20:19:40 +0200 Subject: [PATCH 210/926] StateTimelinePanel: Fix duration on merged values (#58561) Fix stateTimeline duration --- .../grafana-ui/src/components/uPlot/config/addTooltipSupport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts b/packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts index 4d27d14166f..9779f9fe02d 100644 --- a/packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts +++ b/packages/grafana-ui/src/components/uPlot/config/addTooltipSupport.ts @@ -126,7 +126,7 @@ export const addTooltipSupport = ({ } config.addHook('setLegend', (u) => { - if (!isToolTipOpen.current) { + if (!isToolTipOpen.current && !tooltipInterpolator) { setFocusedPointIdx(u.legend.idx!); } if (u.cursor.idxs != null) { From 78bb8c10ce27a903189c880181645b1aab06e664 Mon Sep 17 00:00:00 2001 From: Alex Moreno Date: Fri, 11 Nov 2022 19:58:45 +0100 Subject: [PATCH 211/926] Alerting: Allow none provenance alert rule creation from provisioning API (#58410) --- pkg/services/ngalert/api/api_provisioning.go | 19 +++++++++--- .../ngalert/api/api_provisioning_test.go | 12 +++++--- pkg/services/ngalert/api/tooling/api.json | 29 ++++++++++++++++--- .../definitions/provisioning_alert_rules.go | 6 ++++ pkg/services/ngalert/api/tooling/post.json | 22 ++++++++++++-- pkg/services/ngalert/api/tooling/spec.json | 22 ++++++++++++-- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 8c7e68eb704..7514d0be256 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const disableProvenanceHeaderName = "X-Disable-Provenance" + type ProvisioningSrv struct { log log.Logger policies NotificationPolicyService @@ -259,7 +261,8 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *models.ReqContext, ar definiti if err != nil { return ErrResp(http.StatusBadRequest, err, "") } - createdAlertRule, err := srv.alertRules.CreateAlertRule(c.Req.Context(), upstreamModel, alerting_models.ProvenanceAPI, c.UserID) + provenance := determineProvenance(c) + createdAlertRule, err := srv.alertRules.CreateAlertRule(c.Req.Context(), upstreamModel, provenance, c.UserID) if errors.Is(err, alerting_models.ErrAlertRuleFailedValidation) { return ErrResp(http.StatusBadRequest, err, "") } @@ -273,7 +276,7 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *models.ReqContext, ar definiti return ErrResp(http.StatusInternalServerError, err, "") } - resp := definitions.NewAlertRule(createdAlertRule, alerting_models.ProvenanceAPI) + resp := definitions.NewAlertRule(createdAlertRule, provenance) return response.JSON(http.StatusCreated, resp) } @@ -284,7 +287,8 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *models.ReqContext, ar definitio } updated.OrgID = c.OrgID updated.UID = UID - updatedAlertRule, err := srv.alertRules.UpdateAlertRule(c.Req.Context(), updated, alerting_models.ProvenanceAPI) + provenance := determineProvenance(c) + updatedAlertRule, err := srv.alertRules.UpdateAlertRule(c.Req.Context(), updated, provenance) if errors.Is(err, alerting_models.ErrAlertRuleNotFound) { return response.Empty(http.StatusNotFound) } @@ -298,7 +302,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *models.ReqContext, ar definitio return ErrResp(http.StatusInternalServerError, err, "") } - resp := definitions.NewAlertRule(updatedAlertRule, alerting_models.ProvenanceAPI) + resp := definitions.NewAlertRule(updatedAlertRule, provenance) return response.JSON(http.StatusOK, resp) } @@ -340,3 +344,10 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *models.ReqContext, ag defi } return response.JSON(http.StatusOK, ag) } + +func determineProvenance(ctx *models.ReqContext) alerting_models.Provenance { + if _, disabled := ctx.Req.Header[disableProvenanceHeaderName]; disabled { + return alerting_models.ProvenanceNone + } + return alerting_models.ProvenanceAPI +} diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index c5ca03acda9..4dd16558ddf 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -229,7 +229,7 @@ func TestProvisioningApi(t *testing.T) { t.Run("alert rules", func(t *testing.T) { t.Run("are invalid", func(t *testing.T) { - t.Run("POST returns 400", func(t *testing.T) { + t.Run("POST returns 400 on wrong body params", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() rule := createInvalidAlertRule() @@ -241,7 +241,7 @@ func TestProvisioningApi(t *testing.T) { require.Contains(t, string(response.Body()), "invalid alert rule") }) - t.Run("PUT returns 400", func(t *testing.T) { + t.Run("PUT returns 400 on wrong body params", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() uid := "123123" @@ -258,9 +258,10 @@ func TestProvisioningApi(t *testing.T) { }) t.Run("exist in non-default orgs", func(t *testing.T) { - t.Run("POST sets expected fields", func(t *testing.T) { + t.Run("POST sets expected fields with no provenance", func(t *testing.T) { sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() + rc.Req.Header = map[string][]string{"X-Disable-Provenance": {"true"}} rc.OrgID = 3 rule := createTestAlertRule("rule", 1) @@ -269,15 +270,17 @@ func TestProvisioningApi(t *testing.T) { require.Equal(t, 201, response.Status()) created := deserializeRule(t, response.Body()) require.Equal(t, int64(3), created.OrgID) + require.Equal(t, models.ProvenanceNone, created.Provenance) }) - t.Run("PUT sets expected fields", func(t *testing.T) { + t.Run("PUT sets expected fields with no provenance", func(t *testing.T) { sut := createProvisioningSrvSut(t) uid := t.Name() rule := createTestAlertRule("rule", 1) rule.UID = uid insertRuleInOrg(t, sut, rule, 3) rc := createTestRequestCtx() + rc.Req.Header = map[string][]string{"X-Disable-Provenance": {"hello"}} rc.OrgID = 3 rule.OrgID = 1 // Set the org back to something wrong, we should still prefer the value from the req context. @@ -286,6 +289,7 @@ func TestProvisioningApi(t *testing.T) { require.Equal(t, 200, response.Status()) created := deserializeRule(t, response.Body()) require.Equal(t, int64(3), created.OrgID) + require.Equal(t, models.ProvenanceNone, created.Provenance) }) }) diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 69cadd763a2..d46b63b94a1 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -364,11 +364,14 @@ "description": "A map of RefIDs (unique query identifiers) to this type makes up the Responses property of a QueryDataResponse.\nThe Error property is used to allow for partial success responses from the containing QueryDataResponse.", "properties": { "Error": { - "description": "Error is a property to be set if the the corresponding DataQuery has an error.", + "description": "Error is a property to be set if the corresponding DataQuery has an error.", "type": "string" }, "Frames": { "$ref": "#/definitions/Frames" + }, + "Status": { + "$ref": "#/definitions/Status" } }, "title": "DataResponse contains the results from a DataQuery.", @@ -2816,6 +2819,10 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, + "Status": { + "format": "int64", + "type": "integer" + }, "Success": { "$ref": "#/definitions/ResponseDetails" }, @@ -3070,6 +3077,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -3102,7 +3110,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "Userinfo": { @@ -3258,6 +3266,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3281,6 +3290,7 @@ "type": "object" }, "alertGroups": { + "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -3385,7 +3395,6 @@ "type": "object" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3441,12 +3450,14 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3495,6 +3506,7 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, @@ -3683,7 +3695,6 @@ "type": "object" }, "receiver": { - "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -3816,6 +3827,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "type": "string" } ], "responses": { @@ -3906,6 +3922,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "type": "string" } ], "responses": { diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index c24a53a5910..75c86d20b39 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -57,6 +57,12 @@ type AlertRulePayload struct { Body ProvisionedAlertRule } +// swagger:parameters RoutePostAlertRule RoutePutAlertRule +type AlertRuleHeaders struct { + // in:header + XDisableProvenance string `json:"X-Disable-Provenance"` +} + type ProvisionedAlertRule struct { ID int64 `json:"id"` UID string `json:"uid"` diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 1c32bbdf7be..09e0e92780f 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -364,11 +364,14 @@ "description": "A map of RefIDs (unique query identifiers) to this type makes up the Responses property of a QueryDataResponse.\nThe Error property is used to allow for partial success responses from the containing QueryDataResponse.", "properties": { "Error": { - "description": "Error is a property to be set if the the corresponding DataQuery has an error.", + "description": "Error is a property to be set if the corresponding DataQuery has an error.", "type": "string" }, "Frames": { "$ref": "#/definitions/Frames" + }, + "Status": { + "$ref": "#/definitions/Status" } }, "title": "DataResponse contains the results from a DataQuery.", @@ -2816,6 +2819,10 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, + "Status": { + "format": "int64", + "type": "integer" + }, "Success": { "$ref": "#/definitions/ResponseDetails" }, @@ -3283,7 +3290,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -3443,6 +3449,7 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, @@ -3685,6 +3692,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -5489,6 +5497,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "type": "string" } ], "responses": { @@ -5579,6 +5592,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "in": "header", + "name": "X-Disable-Provenance", + "type": "string" } ], "responses": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 91428fd6cd3..feabdcf0e78 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1707,6 +1707,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { @@ -1778,6 +1783,11 @@ "schema": { "$ref": "#/definitions/ProvisionedAlertRule" } + }, + { + "type": "string", + "name": "X-Disable-Provenance", + "in": "header" } ], "responses": { @@ -2799,11 +2809,14 @@ "title": "DataResponse contains the results from a DataQuery.", "properties": { "Error": { - "description": "Error is a property to be set if the the corresponding DataQuery has an error.", + "description": "Error is a property to be set if the corresponding DataQuery has an error.", "type": "string" }, "Frames": { "$ref": "#/definitions/Frames" + }, + "Status": { + "$ref": "#/definitions/Status" } } }, @@ -5253,6 +5266,10 @@ "SmtpNotEnabled": { "$ref": "#/definitions/ResponseDetails" }, + "Status": { + "type": "integer", + "format": "int64" + }, "Success": { "$ref": "#/definitions/ResponseDetails" }, @@ -5721,7 +5738,6 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -5883,6 +5899,7 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" @@ -6130,6 +6147,7 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { + "description": "Receiver receiver", "type": "object", "required": [ "active", From 6e776d0fec50375ffa89b01e299834b469227f27 Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Fri, 11 Nov 2022 21:04:08 +0200 Subject: [PATCH 212/926] MSSQL: Add connection timeout setting in configuration page (#58631) * MSSQL add connection timeout * add docs * Update docs and add min value to the timeout setting --- docs/sources/datasources/mssql/_index.md | 5 ++++ pkg/tsdb/mssql/mssql.go | 14 +++++++---- pkg/tsdb/sqleng/sql_engine.go | 1 + .../configuration/ConfigurationEditor.tsx | 24 +++++++++++++++++++ public/app/plugins/datasource/mssql/types.ts | 1 + 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/docs/sources/datasources/mssql/_index.md b/docs/sources/datasources/mssql/_index.md index dd5f08a3dcd..30ac2bb5f3d 100644 --- a/docs/sources/datasources/mssql/_index.md +++ b/docs/sources/datasources/mssql/_index.md @@ -77,6 +77,10 @@ For example, use `1m` if Microsoft SQL Server writes data every minute. You can also override this setting in a dashboard panel under its data source options. +### Connection timeout + +The **Connection timeout** setting defines the maximum number of seconds to wait for a connection to the database before timing out. Default is 0 for no timeout. + ### Database user permissions Grafana doesn't validate that a query is safe, and could include any SQL statement. @@ -119,6 +123,7 @@ datasources: maxOpenConns: 0 # Grafana v5.4+ maxIdleConns: 2 # Grafana v5.4+ connMaxLifetime: 14400 # Grafana v5.4+ + connectionTimeout: 0 # Grafana v9.3+ secureJsonData: password: 'Password!' ``` diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index f2312a822cd..adb90f85988 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -55,10 +55,11 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) func newInstanceSettings(cfg *setting.Cfg) datasource.InstanceFactoryFunc { return func(settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { jsonData := sqleng.JsonData{ - MaxOpenConns: 0, - MaxIdleConns: 2, - ConnMaxLifetime: 14400, - Encrypt: "false", + MaxOpenConns: 0, + MaxIdleConns: 2, + ConnMaxLifetime: 14400, + Encrypt: "false", + ConnectionTimeout: 0, } err := json.Unmarshal(settings.JSONData, &jsonData) @@ -171,6 +172,11 @@ func generateConnectionString(dsInfo sqleng.DataSourceInfo) (string, error) { } else if encrypt == "disable" { connStr += fmt.Sprintf("encrypt=%s;", dsInfo.JsonData.Encrypt) } + + if dsInfo.JsonData.ConnectionTimeout != 0 { + connStr += fmt.Sprintf("connection timeout=%d;", dsInfo.JsonData.ConnectionTimeout) + } + return connStr, nil } diff --git a/pkg/tsdb/sqleng/sql_engine.go b/pkg/tsdb/sqleng/sql_engine.go index bce2ef26312..a837ec8bf63 100644 --- a/pkg/tsdb/sqleng/sql_engine.go +++ b/pkg/tsdb/sqleng/sql_engine.go @@ -54,6 +54,7 @@ type JsonData struct { MaxOpenConns int `json:"maxOpenConns"` MaxIdleConns int `json:"maxIdleConns"` ConnMaxLifetime int `json:"connMaxLifetime"` + ConnectionTimeout int `json:"connectionTimeout"` Timescaledb bool `json:"timescaledb"` Mode string `json:"sslmode"` ConfigurationMethod string `json:"tlsConfigurationMethod"` diff --git a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx index 80f6a3c7ec7..c17468f45a5 100644 --- a/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx +++ b/public/app/plugins/datasource/mssql/configuration/ConfigurationEditor.tsx @@ -21,6 +21,7 @@ import { Select, useStyles2, } from '@grafana/ui'; +import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; import { ConnectionLimits } from 'app/features/plugins/sql/components/configuration/ConnectionLimits'; import { MSSQLAuthenticationType, MSSQLEncryptOptions, MssqlOptions } from '../types'; @@ -60,6 +61,10 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps { + updateDatasourcePluginJsonDataOption(props, 'connectionTimeout', connectionTimeout ?? 0); + }; + const authenticationOptions: Array> = [ { value: MSSQLAuthenticationType.sqlAuth, label: 'SQL Server Authentication' }, { value: MSSQLAuthenticationType.windowsAuth, label: 'Windows Authentication' }, @@ -74,6 +79,7 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps @@ -233,6 +239,7 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps } label="Min time interval" + labelWidth={labelWidthDetails} > + + The number of seconds to wait before canceling the request when connecting to the database. The default is{' '} + 0, meaning no timeout. + + } + label="Connection timeout" + labelWidth={labelWidthDetails} + > + + diff --git a/public/app/plugins/datasource/mssql/types.ts b/public/app/plugins/datasource/mssql/types.ts index 2f93dd07977..c44d0bda761 100644 --- a/public/app/plugins/datasource/mssql/types.ts +++ b/public/app/plugins/datasource/mssql/types.ts @@ -15,4 +15,5 @@ export interface MssqlOptions extends SQLOptions { encrypt?: MSSQLEncryptOptions; sslRootCertFile?: string; serverName?: string; + connectionTimeout?: number; } From 75e435fb00942483c421d052066225b9f5fe9ce4 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Fri, 11 Nov 2022 22:41:38 +0100 Subject: [PATCH 213/926] SQL: Fix issue where testing the datasource would always be successful if the `datasourceQueryMultiStatus` feature was enabled (#58671) SQL Datasources: fix issue where testing the datasource connection would show success even when there was an error. Co-authored-by: Victor Marin --- .../plugins/sql/datasource/SqlDatasource.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/public/app/features/plugins/sql/datasource/SqlDatasource.ts b/public/app/features/plugins/sql/datasource/SqlDatasource.ts index bc341e3f98e..9a71dbe11bc 100644 --- a/public/app/features/plugins/sql/datasource/SqlDatasource.ts +++ b/public/app/features/plugins/sql/datasource/SqlDatasource.ts @@ -165,9 +165,10 @@ export abstract class SqlDatasource extends DataSourceWithBackend { + const refId = 'A'; return lastValueFrom( getBackendSrv() - .fetch({ + .fetch({ url: '/api/ds/query', method: 'POST', data: { @@ -175,7 +176,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend ({ status: 'success', message: 'Database Connection OK' })), + map((r) => { + const error = r.data.results[refId].error; + if (error) { + return { status: 'error', message: error }; + } + return { status: 'success', message: 'Database Connection OK' }; + }), catchError((err) => { return of(toTestingStatus(err)); }) From 69b5a9c752f3dd05faf7159d73874b08fec7a524 Mon Sep 17 00:00:00 2001 From: ying-jeanne <74549700+ying-jeanne@users.noreply.github.com> Date: Sat, 12 Nov 2022 15:51:46 +0100 Subject: [PATCH 214/926] Chore: [Nested Folder] Add db migration at service start time (#58590) * add db migration at service start time * make changes for the 3 db * revert migrator * fix feature toggle check Co-authored-by: Serge Zaitsev --- pkg/services/folder/folderimpl/folder.go | 26 ++++++++++++++++++- .../sqlstore/migrations/folder_mig.go | 12 +-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 6f3d1818b36..61aa379b549 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -15,6 +15,8 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/services/guardian" @@ -54,7 +56,7 @@ func ProvideService( ac.RegisterScopeAttributeResolver(dashboards.NewFolderNameScopeResolver(dashboardStore)) ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(dashboardStore)) store := ProvideStore(db, cfg, features) - return &Service{ + svr := &Service{ cfg: cfg, log: log.New("folder-service"), dashboardService: dashboardService, @@ -66,6 +68,28 @@ func ProvideService( accessControl: ac, bus: bus, } + if features.IsEnabled(featuremgmt.FlagNestedFolders) { + svr.DBMigration(db) + } + return svr +} + +func (s *Service) DBMigration(db db.DB) { + ctx := context.Background() + err := db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + var err error + if db.GetDialect().DriverName() == migrator.SQLite { + _, err = sess.Exec("INSERT OR REPLACE INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1") + } else if db.GetDialect().DriverName() == migrator.Postgres { + _, err = sess.Exec("INSERT INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = true ON CONFLICT DO NOTHING") + } else { + _, err = sess.Exec("INSERT IGNORE INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1") + } + return err + }) + if err != nil { + s.log.Error("DB migration on folder service start failed.") + } } func (s *Service) Get(ctx context.Context, cmd *folder.GetFolderQuery) (*folder.Folder, error) { diff --git a/pkg/services/sqlstore/migrations/folder_mig.go b/pkg/services/sqlstore/migrations/folder_mig.go index fd9835612dd..71938ed791b 100644 --- a/pkg/services/sqlstore/migrations/folder_mig.go +++ b/pkg/services/sqlstore/migrations/folder_mig.go @@ -1,9 +1,6 @@ package migrations import ( - "fmt" - - "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -11,13 +8,6 @@ import ( func addFolderMigrations(mg *migrator.Migrator) { mg.AddMigration("create folder table", migrator.NewAddTableMigration(folderv1())) - // copy any existing folders in the dashboard table into the new folder - // table. The *legacy* parent folder ID, stored as folder_id in the - // dashboard table, is always going to be "0" so it is safe to convert to a parent UID. - mg.AddMigration("copy existing folders from dashboard table", migrator.NewRawSQLMigration( - "INSERT INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1;", - ).Postgres("INSERT INTO folder (id, uid, org_id, title, created, updated) SELECT id, uid, org_id, title, created, updated FROM dashboard WHERE is_folder = true;")) - mg.AddMigration("Add index for parent_uid", migrator.NewAddIndexMigration(folderv1(), &migrator.Index{ Cols: []string{"parent_uid", "org_id"}, })) @@ -43,7 +33,7 @@ func folderv1() migrator.Table { {Name: "org_id", Type: migrator.DB_BigInt, Nullable: false}, {Name: "title", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, {Name: "description", Type: migrator.DB_NVarchar, Length: 255, Nullable: true}, - {Name: "parent_uid", Type: migrator.DB_NVarchar, Length: 40, Default: fmt.Sprintf("'%s'", folder.GeneralFolderUID)}, + {Name: "parent_uid", Type: migrator.DB_NVarchar, Length: 40, Default: ""}, {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, }, From 59344074433b6c6f517441d0dc1695f2e5c675b7 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Sat, 12 Nov 2022 11:36:18 -0800 Subject: [PATCH 215/926] Storage: add an admin write flavor that can explicitly set the user/time (#58618) --- pkg/services/export/object_store.go | 44 +- .../sqlstore/migrations/object_store_mig.go | 8 +- .../store/object/dummy/dummy_server.go | 17 +- pkg/services/store/object/dummy/fake_store.go | 8 + pkg/services/store/object/json.go | 12 +- pkg/services/store/object/object.pb.go | 752 +++++++++++------- pkg/services/store/object/object.proto | 61 +- pkg/services/store/object/object_grpc.pb.go | 122 +++ .../object/sqlstash/sql_storage_server.go | 179 +++-- pkg/services/store/object/utils.go | 11 + 10 files changed, 845 insertions(+), 369 deletions(-) create mode 100644 pkg/services/store/object/utils.go diff --git a/pkg/services/export/object_store.go b/pkg/services/export/object_store.go index 1b902e64589..10d3ec96719 100644 --- a/pkg/services/export/object_store.go +++ b/pkg/services/export/object_store.go @@ -123,14 +123,21 @@ func (e *objectStoreJob) start(ctx context.Context) { rowUser.UserID = 0 // avoid Uint64Val issue???? } - _, err = e.store.Write(ctx, &object.WriteObjectRequest{ + _, err = e.store.AdminWrite(ctx, &object.AdminWriteObjectRequest{ GRN: &object.GRN{ Scope: models.ObjectStoreScopeEntity, UID: dash.UID, Kind: models.StandardKindDashboard, }, - Body: dash.Body, - Comment: "export from dashboard table", + ClearHistory: true, + Version: fmt.Sprintf("%d", dash.Version), + CreatedAt: dash.Created.UnixMilli(), + UpdatedAt: dash.Updated.UnixMilli(), + UpdatedBy: fmt.Sprintf("user:%d", dash.UpdatedBy), + CreatedBy: fmt.Sprintf("user:%d", dash.CreatedBy), + Origin: "export-from-sql", + Body: dash.Data, + Comment: "(exported from SQL)", }) if err != nil { e.status.Status = "error: " + err.Error() @@ -254,34 +261,25 @@ func (e *objectStoreJob) start(ctx context.Context) { } type dashInfo struct { - OrgID int64 + OrgID int64 `db:"org_id"` UID string - Body []byte - UpdatedBy int64 + Version int64 + Slug string + Data []byte + Created time.Time + Updated time.Time + CreatedBy int64 `db:"created_by"` + UpdatedBy int64 `db:"updated_by"` } +// TODO, paging etc func (e *objectStoreJob) getDashboards(ctx context.Context) ([]dashInfo, error) { e.status.Last = "find dashbaords...." e.broadcaster(e.status) dash := make([]dashInfo, 0) - rows, err := e.sess.Query(ctx, "SELECT org_id,uid,data,updated_by FROM dashboard WHERE is_folder=false") - if err != nil { - return nil, err - } - for rows.Next() { - if e.stopRequested { - return dash, nil - } - - row := dashInfo{} - err = rows.Scan(&row.OrgID, &row.UID, &row.Body, &row.UpdatedBy) - if err != nil { - return nil, err - } - dash = append(dash, row) - } - return dash, nil + err := e.sess.Select(ctx, &dash, "SELECT org_id,uid,version,slug,data,created,updated,created_by,updated_by FROM dashboard WHERE is_folder=false") + return dash, err } func (e *objectStoreJob) getStatus() ExportStatus { diff --git a/pkg/services/sqlstore/migrations/object_store_mig.go b/pkg/services/sqlstore/migrations/object_store_mig.go index f2053e33407..6f87b1d6265 100644 --- a/pkg/services/sqlstore/migrations/object_store_mig.go +++ b/pkg/services/sqlstore/migrations/object_store_mig.go @@ -46,9 +46,9 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { {Name: "updated_by", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, {Name: "created_by", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - // For objects that are synchronized from an external source (ie provisioning or git) - {Name: "sync_src", Type: migrator.DB_Text, Nullable: true}, - {Name: "sync_time", Type: migrator.DB_BigInt, Nullable: true}, + // Mark objects with origin metadata + {Name: "origin", Type: migrator.DB_Text, Nullable: true}, + {Name: "origin_ts", Type: migrator.DB_BigInt, Nullable: false}, // Summary data (always extracted from the `body` column) {Name: "name", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, @@ -134,7 +134,7 @@ func addObjectStorageMigrations(mg *migrator.Migrator) { // Migration cleanups: given that this is a complex setup // that requires a lot of testing before we are ready to push out of dev // this script lets us easy wipe previous changes and initialize clean tables - suffix := " (v2)" // change this when we want to wipe and reset the object tables + suffix := " (v5)" // change this when we want to wipe and reset the object tables mg.AddMigration("ObjectStore init: cleanup"+suffix, migrator.NewRawSQLMigration(strings.TrimSpace(` DELETE FROM migration_log WHERE migration_id LIKE 'ObjectStore init%'; `))) diff --git a/pkg/services/store/object/dummy/dummy_server.go b/pkg/services/store/object/dummy/dummy_server.go index 323f7b49082..4e5fbe6f721 100644 --- a/pkg/services/store/object/dummy/dummy_server.go +++ b/pkg/services/store/object/dummy/dummy_server.go @@ -35,6 +35,10 @@ var ( rawObjectVersion = 9 ) +// Make sure we implement both store + admin +var _ object.ObjectStoreServer = &dummyObjectServer{} +var _ object.ObjectStoreAdminServer = &dummyObjectServer{} + func ProvideDummyObjectServer(cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry) object.ObjectStoreServer { objectServer := &dummyObjectServer{ collection: persistentcollection.NewLocalFSPersistentCollection[*RawObjectWithHistory]("raw-object", cfg.DataPath, rawObjectVersion), @@ -149,7 +153,7 @@ func createContentsHash(contents []byte) string { return hex.EncodeToString(hash[:]) } -func (i *dummyObjectServer) update(ctx context.Context, r *object.WriteObjectRequest, namespace string) (*object.WriteObjectResponse, error) { +func (i *dummyObjectServer) update(ctx context.Context, r *object.AdminWriteObjectRequest, namespace string) (*object.WriteObjectResponse, error) { builder := i.kinds.GetSummaryBuilder(r.GRN.Kind) if builder == nil { return nil, fmt.Errorf("unsupported kind: " + r.GRN.Kind) @@ -222,7 +226,7 @@ func (i *dummyObjectServer) update(ctx context.Context, r *object.WriteObjectReq return rsp, nil } -func (i *dummyObjectServer) insert(ctx context.Context, r *object.WriteObjectRequest, namespace string) (*object.WriteObjectResponse, error) { +func (i *dummyObjectServer) insert(ctx context.Context, r *object.AdminWriteObjectRequest, namespace string) (*object.WriteObjectResponse, error) { modifier := store.GetUserIDString(store.UserFromContext(ctx)) rawObj := &object.RawObject{ GRN: r.GRN, @@ -266,6 +270,15 @@ func (i *dummyObjectServer) insert(ctx context.Context, r *object.WriteObjectReq } func (i *dummyObjectServer) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { + return i.doWrite(ctx, object.ToAdminWriteObjectRequest(r)) +} + +func (i *dummyObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { + // Check permissions? + return i.doWrite(ctx, r) +} + +func (i *dummyObjectServer) doWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { grn := getFullGRN(ctx, r.GRN) namespace := namespaceFromUID(grn) obj, err := i.collection.FindFirst(ctx, namespace, func(i *RawObjectWithHistory) (bool, error) { diff --git a/pkg/services/store/object/dummy/fake_store.go b/pkg/services/store/object/dummy/fake_store.go index 092f4ce9f43..c85c56ebd68 100644 --- a/pkg/services/store/object/dummy/fake_store.go +++ b/pkg/services/store/object/dummy/fake_store.go @@ -7,12 +7,20 @@ import ( "github.com/grafana/grafana/pkg/services/store/object" ) +// Make sure we implement both store + admin +var _ object.ObjectStoreServer = &fakeObjectStore{} +var _ object.ObjectStoreAdminServer = &fakeObjectStore{} + func ProvideFakeObjectServer() object.ObjectStoreServer { return &fakeObjectStore{} } type fakeObjectStore struct{} +func (i fakeObjectStore) AdminWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { + return nil, fmt.Errorf("unimplemented") +} + func (i fakeObjectStore) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { return nil, fmt.Errorf("unimplemented") } diff --git a/pkg/services/store/object/json.go b/pkg/services/store/object/json.go index 45b9995bfa4..b3c4e20d839 100644 --- a/pkg/services/store/object/json.go +++ b/pkg/services/store/object/json.go @@ -102,10 +102,10 @@ func (codec *rawObjectCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) stream.WriteInt64(obj.Size) } - if obj.Sync != nil { + if obj.Origin != nil { stream.WriteMore() - stream.WriteObjectField("sync") - stream.WriteVal(obj.Sync) + stream.WriteObjectField("origin") + stream.WriteVal(obj.Origin) } stream.WriteObjectEnd() @@ -137,9 +137,9 @@ func readRawObject(iter *jsoniter.Iterator, raw *RawObject) { raw.ETag = iter.ReadString() case "version": raw.Version = iter.ReadString() - case "sync": - raw.Sync = &RawObjectSyncInfo{} - iter.ReadVal(raw.Sync) + case "origin": + raw.Origin = &ObjectOriginInfo{} + iter.ReadVal(raw.Origin) case "body": var val interface{} diff --git a/pkg/services/store/object/object.pb.go b/pkg/services/store/object/object.pb.go index 78248fedb08..b37ab17330d 100644 --- a/pkg/services/store/object/object.pb.go +++ b/pkg/services/store/object/object.pb.go @@ -70,7 +70,7 @@ func (x WriteObjectResponse_Status) Number() protoreflect.EnumNumber { // Deprecated: Use WriteObjectResponse_Status.Descriptor instead. func (WriteObjectResponse_Status) EnumDescriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{10, 0} + return file_object_proto_rawDescGZIP(), []int{11, 0} } type GRN struct { @@ -179,7 +179,7 @@ type RawObject struct { // NOTE: currently managed by the dashboard+dashboard_version tables Version string `protobuf:"bytes,9,opt,name=version,proto3" json:"version,omitempty"` // External location info - Sync *RawObjectSyncInfo `protobuf:"bytes,10,opt,name=sync,proto3" json:"sync,omitempty"` + Origin *ObjectOriginInfo `protobuf:"bytes,10,opt,name=origin,proto3" json:"origin,omitempty"` } func (x *RawObject) Reset() { @@ -277,14 +277,14 @@ func (x *RawObject) GetVersion() string { return "" } -func (x *RawObject) GetSync() *RawObjectSyncInfo { +func (x *RawObject) GetOrigin() *ObjectOriginInfo { if x != nil { - return x.Sync + return x.Origin } return nil } -type RawObjectSyncInfo struct { +type ObjectOriginInfo struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields @@ -295,8 +295,8 @@ type RawObjectSyncInfo struct { Time int64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` } -func (x *RawObjectSyncInfo) Reset() { - *x = RawObjectSyncInfo{} +func (x *ObjectOriginInfo) Reset() { + *x = ObjectOriginInfo{} if protoimpl.UnsafeEnabled { mi := &file_object_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -304,13 +304,13 @@ func (x *RawObjectSyncInfo) Reset() { } } -func (x *RawObjectSyncInfo) String() string { +func (x *ObjectOriginInfo) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RawObjectSyncInfo) ProtoMessage() {} +func (*ObjectOriginInfo) ProtoMessage() {} -func (x *RawObjectSyncInfo) ProtoReflect() protoreflect.Message { +func (x *ObjectOriginInfo) ProtoReflect() protoreflect.Message { mi := &file_object_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -322,19 +322,19 @@ func (x *RawObjectSyncInfo) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RawObjectSyncInfo.ProtoReflect.Descriptor instead. -func (*RawObjectSyncInfo) Descriptor() ([]byte, []int) { +// Deprecated: Use ObjectOriginInfo.ProtoReflect.Descriptor instead. +func (*ObjectOriginInfo) Descriptor() ([]byte, []int) { return file_object_proto_rawDescGZIP(), []int{2} } -func (x *RawObjectSyncInfo) GetSource() string { +func (x *ObjectOriginInfo) GetSource() string { if x != nil { return x.Source } return "" } -func (x *RawObjectSyncInfo) GetTime() int64 { +func (x *ObjectOriginInfo) GetTime() int64 { if x != nil { return x.Time } @@ -806,6 +806,154 @@ func (x *WriteObjectRequest) GetPreviousVersion() string { return "" } +// This operation is useful when syncing a resource from external sources +// that have more accurate metadata information (git, or an archive). +// This process can bypass the forced checks that +type AdminWriteObjectRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Object identifier + GRN *GRN `protobuf:"bytes,1,opt,name=GRN,proto3" json:"GRN,omitempty"` + // The raw object body + Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + // Message that can be seen when exploring object history + Comment string `protobuf:"bytes,3,opt,name=comment,proto3" json:"comment,omitempty"` + // Time in epoch milliseconds that the object was created + // Optional, if 0 it will use the current time + CreatedAt int64 `protobuf:"varint,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Time in epoch milliseconds that the object was updated + // Optional, if empty it will use the current user + UpdatedAt int64 `protobuf:"varint,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // Who created the object + // Optional, if 0 it will use the current time + CreatedBy string `protobuf:"bytes,6,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` + // Who updated the object + // Optional, if empty it will use the current user + UpdatedBy string `protobuf:"bytes,7,opt,name=updated_by,json=updatedBy,proto3" json:"updated_by,omitempty"` + // An explicit version identifier + // Optional, if set, this will overwrite/define an explicit version + Version string `protobuf:"bytes,8,opt,name=version,proto3" json:"version,omitempty"` + // Used for optimistic locking. If missing, the previous version will be replaced regardless + // This may not be used along with an explicit version in the request + PreviousVersion string `protobuf:"bytes,9,opt,name=previous_version,json=previousVersion,proto3" json:"previous_version,omitempty"` + // Request that all previous versions are removed from the history + // This will make sense for systems that manage history explicitly externallay + ClearHistory bool `protobuf:"varint,10,opt,name=clear_history,json=clearHistory,proto3" json:"clear_history,omitempty"` + // Optionally define where the object came from + Origin string `protobuf:"bytes,11,opt,name=origin,proto3" json:"origin,omitempty"` +} + +func (x *AdminWriteObjectRequest) Reset() { + *x = AdminWriteObjectRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_object_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AdminWriteObjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminWriteObjectRequest) ProtoMessage() {} + +func (x *AdminWriteObjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_object_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminWriteObjectRequest.ProtoReflect.Descriptor instead. +func (*AdminWriteObjectRequest) Descriptor() ([]byte, []int) { + return file_object_proto_rawDescGZIP(), []int{10} +} + +func (x *AdminWriteObjectRequest) GetGRN() *GRN { + if x != nil { + return x.GRN + } + return nil +} + +func (x *AdminWriteObjectRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *AdminWriteObjectRequest) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + +func (x *AdminWriteObjectRequest) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *AdminWriteObjectRequest) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 +} + +func (x *AdminWriteObjectRequest) GetCreatedBy() string { + if x != nil { + return x.CreatedBy + } + return "" +} + +func (x *AdminWriteObjectRequest) GetUpdatedBy() string { + if x != nil { + return x.UpdatedBy + } + return "" +} + +func (x *AdminWriteObjectRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AdminWriteObjectRequest) GetPreviousVersion() string { + if x != nil { + return x.PreviousVersion + } + return "" +} + +func (x *AdminWriteObjectRequest) GetClearHistory() bool { + if x != nil { + return x.ClearHistory + } + return false +} + +func (x *AdminWriteObjectRequest) GetOrigin() string { + if x != nil { + return x.Origin + } + return "" +} + type WriteObjectResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -826,7 +974,7 @@ type WriteObjectResponse struct { func (x *WriteObjectResponse) Reset() { *x = WriteObjectResponse{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[10] + mi := &file_object_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -839,7 +987,7 @@ func (x *WriteObjectResponse) String() string { func (*WriteObjectResponse) ProtoMessage() {} func (x *WriteObjectResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[10] + mi := &file_object_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -852,7 +1000,7 @@ func (x *WriteObjectResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteObjectResponse.ProtoReflect.Descriptor instead. func (*WriteObjectResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{10} + return file_object_proto_rawDescGZIP(), []int{11} } func (x *WriteObjectResponse) GetError() *ObjectErrorInfo { @@ -904,7 +1052,7 @@ type DeleteObjectRequest struct { func (x *DeleteObjectRequest) Reset() { *x = DeleteObjectRequest{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[11] + mi := &file_object_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -917,7 +1065,7 @@ func (x *DeleteObjectRequest) String() string { func (*DeleteObjectRequest) ProtoMessage() {} func (x *DeleteObjectRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[11] + mi := &file_object_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -930,7 +1078,7 @@ func (x *DeleteObjectRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteObjectRequest.ProtoReflect.Descriptor instead. func (*DeleteObjectRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{11} + return file_object_proto_rawDescGZIP(), []int{12} } func (x *DeleteObjectRequest) GetGRN() *GRN { @@ -958,7 +1106,7 @@ type DeleteObjectResponse struct { func (x *DeleteObjectResponse) Reset() { *x = DeleteObjectResponse{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[12] + mi := &file_object_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -971,7 +1119,7 @@ func (x *DeleteObjectResponse) String() string { func (*DeleteObjectResponse) ProtoMessage() {} func (x *DeleteObjectResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[12] + mi := &file_object_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -984,7 +1132,7 @@ func (x *DeleteObjectResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteObjectResponse.ProtoReflect.Descriptor instead. func (*DeleteObjectResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{12} + return file_object_proto_rawDescGZIP(), []int{13} } func (x *DeleteObjectResponse) GetOK() bool { @@ -1010,7 +1158,7 @@ type ObjectHistoryRequest struct { func (x *ObjectHistoryRequest) Reset() { *x = ObjectHistoryRequest{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[13] + mi := &file_object_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1023,7 +1171,7 @@ func (x *ObjectHistoryRequest) String() string { func (*ObjectHistoryRequest) ProtoMessage() {} func (x *ObjectHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[13] + mi := &file_object_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1036,7 +1184,7 @@ func (x *ObjectHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ObjectHistoryRequest.ProtoReflect.Descriptor instead. func (*ObjectHistoryRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{13} + return file_object_proto_rawDescGZIP(), []int{14} } func (x *ObjectHistoryRequest) GetGRN() *GRN { @@ -1076,7 +1224,7 @@ type ObjectHistoryResponse struct { func (x *ObjectHistoryResponse) Reset() { *x = ObjectHistoryResponse{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[14] + mi := &file_object_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1089,7 +1237,7 @@ func (x *ObjectHistoryResponse) String() string { func (*ObjectHistoryResponse) ProtoMessage() {} func (x *ObjectHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[14] + mi := &file_object_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1102,7 +1250,7 @@ func (x *ObjectHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ObjectHistoryResponse.ProtoReflect.Descriptor instead. func (*ObjectHistoryResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{14} + return file_object_proto_rawDescGZIP(), []int{15} } func (x *ObjectHistoryResponse) GetGRN() *GRN { @@ -1156,7 +1304,7 @@ type ObjectSearchRequest struct { func (x *ObjectSearchRequest) Reset() { *x = ObjectSearchRequest{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[15] + mi := &file_object_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1169,7 +1317,7 @@ func (x *ObjectSearchRequest) String() string { func (*ObjectSearchRequest) ProtoMessage() {} func (x *ObjectSearchRequest) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[15] + mi := &file_object_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1182,7 +1330,7 @@ func (x *ObjectSearchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ObjectSearchRequest.ProtoReflect.Descriptor instead. func (*ObjectSearchRequest) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{15} + return file_object_proto_rawDescGZIP(), []int{16} } func (x *ObjectSearchRequest) GetNextPageToken() string { @@ -1288,7 +1436,7 @@ type ObjectSearchResult struct { func (x *ObjectSearchResult) Reset() { *x = ObjectSearchResult{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[16] + mi := &file_object_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1301,7 +1449,7 @@ func (x *ObjectSearchResult) String() string { func (*ObjectSearchResult) ProtoMessage() {} func (x *ObjectSearchResult) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[16] + mi := &file_object_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1314,7 +1462,7 @@ func (x *ObjectSearchResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ObjectSearchResult.ProtoReflect.Descriptor instead. func (*ObjectSearchResult) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{16} + return file_object_proto_rawDescGZIP(), []int{17} } func (x *ObjectSearchResult) GetGRN() *GRN { @@ -1407,7 +1555,7 @@ type ObjectSearchResponse struct { func (x *ObjectSearchResponse) Reset() { *x = ObjectSearchResponse{} if protoimpl.UnsafeEnabled { - mi := &file_object_proto_msgTypes[17] + mi := &file_object_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1420,7 +1568,7 @@ func (x *ObjectSearchResponse) String() string { func (*ObjectSearchResponse) ProtoMessage() {} func (x *ObjectSearchResponse) ProtoReflect() protoreflect.Message { - mi := &file_object_proto_msgTypes[17] + mi := &file_object_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1433,7 +1581,7 @@ func (x *ObjectSearchResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ObjectSearchResponse.ProtoReflect.Descriptor instead. func (*ObjectSearchResponse) Descriptor() ([]byte, []int) { - return file_object_proto_rawDescGZIP(), []int{17} + return file_object_proto_rawDescGZIP(), []int{18} } func (x *ObjectSearchResponse) GetResults() []*ObjectSearchResult { @@ -1460,7 +1608,7 @@ var file_object_proto_rawDesc = []byte{ 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, 0x22, 0xab, 0x02, 0x0a, 0x09, 0x52, 0x61, 0x77, 0x4f, 0x62, + 0x09, 0x52, 0x03, 0x55, 0x49, 0x44, 0x22, 0xae, 0x02, 0x0a, 0x09, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, @@ -1476,196 +1624,230 @@ var file_object_proto_rawDesc = []byte{ 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x04, 0x73, 0x79, 0x6e, 0x63, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x79, 0x6e, 0x63, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x04, - 0x73, 0x79, 0x6e, 0x63, 0x22, 0x3f, 0x0a, 0x11, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x53, 0x79, 0x6e, 0x63, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, - 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, - 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x64, 0x65, - 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0xad, 0x01, 0x0a, 0x11, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, - 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x45, - 0x54, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, 0x67, 0x12, - 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x8c, 0x01, 0x0a, 0x11, 0x52, 0x65, - 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, - 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, - 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, - 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x73, 0x75, - 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, 0x69, 0x74, - 0x68, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0x62, 0x0a, 0x12, 0x52, 0x65, 0x61, 0x64, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x29, - 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, - 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x49, 0x0a, 0x16, - 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, - 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x4f, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, - 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x52, - 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x8c, 0x01, 0x0a, 0x12, 0x57, 0x72, 0x69, - 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x12, - 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, - 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, - 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xb3, 0x02, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, - 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, - 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x72, - 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, - 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x31, 0x0a, - 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, - 0x73, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, - 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, - 0x3c, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, - 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, - 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0d, - 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, 0x44, 0x10, 0x03, 0x22, 0x5f, 0x0a, - 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, - 0x47, 0x52, 0x4e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, - 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x26, - 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x4b, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x02, 0x4f, 0x4b, 0x22, 0x73, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, - 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x14, 0x0a, - 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, - 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, - 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, - 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x15, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, - 0x03, 0x47, 0x52, 0x4e, 0x12, 0x35, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, + 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x22, 0x3e, 0x0a, 0x10, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x62, 0x0a, 0x0f, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, + 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x18, + 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x74, 0x61, + 0x69, 0x6c, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, + 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0xad, 0x01, 0x0a, 0x11, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, - 0x6f, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, - 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x22, 0x84, 0x03, 0x0a, 0x13, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, - 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, - 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, - 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x6b, - 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3f, 0x0a, 0x06, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, - 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, - 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, - 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, - 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, 0x68, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, - 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xa4, 0x03, 0x0a, 0x12, 0x4f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x75, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x45, 0x54, 0x61, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x45, 0x54, 0x61, + 0x67, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x8c, 0x01, 0x0a, 0x11, + 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, - 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1d, - 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, - 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, - 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, - 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, - 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, - 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, - 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, - 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x66, 0x69, 0x65, - 0x6c, 0x64, 0x73, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, 0x6f, 0x72, - 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x4a, 0x73, 0x6f, 0x6e, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x74, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, - 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, - 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0xae, 0x03, 0x0a, 0x0b, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3d, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, - 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x09, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, - 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, + 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, + 0x74, 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, + 0x69, 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x77, 0x69, 0x74, 0x68, 0x5f, + 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, + 0x69, 0x74, 0x68, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0x62, 0x0a, 0x12, 0x52, 0x65, + 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x29, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x11, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x61, 0x77, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x22, 0x49, + 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x05, 0x62, 0x61, 0x74, 0x63, + 0x68, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x52, 0x05, 0x62, 0x61, 0x74, 0x63, 0x68, 0x22, 0x4f, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1a, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, - 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1b, 0x2e, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x3b, 0x6f, - 0x62, 0x6a, 0x65, 0x63, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, + 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x8c, 0x01, 0x0a, 0x12, 0x57, + 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, + 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, + 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, + 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x29, + 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, + 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xe4, 0x02, 0x0a, 0x17, 0x41, 0x64, + 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, + 0x03, 0x47, 0x52, 0x4e, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, + 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, + 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, + 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x18, + 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, + 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x5f, 0x68, 0x69, 0x73, + 0x74, 0x6f, 0x72, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x63, 0x6c, 0x65, 0x61, + 0x72, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, + 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, + 0x22, 0xb3, 0x02, 0x0a, 0x13, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x49, 0x6e, 0x66, 0x6f, + 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, + 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x31, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, + 0x6f, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x6d, + 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x0b, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x3c, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x00, 0x12, 0x0b, 0x0a, + 0x07, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x50, + 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x43, 0x48, 0x41, + 0x4e, 0x47, 0x45, 0x44, 0x10, 0x03, 0x22, 0x5f, 0x0a, 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, + 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x29, 0x0a, 0x10, + 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x26, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x0e, 0x0a, 0x02, 0x4f, 0x4b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x4f, 0x4b, 0x22, + 0x73, 0x0a, 0x14, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, + 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x26, 0x0a, 0x0f, + 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x95, 0x01, 0x0a, 0x15, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, + 0x0a, 0x03, 0x47, 0x52, 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x35, 0x0a, + 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, + 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, + 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x84, 0x03, 0x0a, + 0x13, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, + 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, + 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, + 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, + 0x69, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, + 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, + 0x6c, 0x64, 0x65, 0x72, 0x12, 0x3f, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x06, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x69, 0x74, + 0x68, 0x5f, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x77, 0x69, + 0x74, 0x68, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, 0x6c, + 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, 0x74, + 0x68, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x77, 0x69, 0x74, 0x68, 0x5f, + 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x77, 0x69, + 0x74, 0x68, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x22, 0xa4, 0x03, 0x0a, 0x12, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1d, 0x0a, 0x03, 0x47, 0x52, + 0x4e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, + 0x2e, 0x47, 0x52, 0x4e, 0x52, 0x03, 0x47, 0x52, 0x4e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x64, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x64, 0x42, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, + 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x3e, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x26, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, + 0x1f, 0x0a, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0a, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x4a, 0x73, 0x6f, 0x6e, + 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4a, 0x73, 0x6f, 0x6e, 0x1a, + 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x74, 0x0a, 0x14, 0x4f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x34, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, + 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, + 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x32, 0xfa, 0x03, 0x0a, 0x0b, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, + 0x12, 0x3d, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x19, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x52, 0x65, 0x61, + 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x4c, 0x0a, 0x09, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x12, 0x1e, 0x2e, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x64, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, + 0x05, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, + 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x43, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, + 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, + 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, + 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x48, 0x69, 0x73, + 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, + 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, + 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x4f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x4a, 0x0a, 0x0a, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, + 0x1f, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, + 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x5e, 0x0a, + 0x10, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x41, 0x64, 0x6d, 0x69, + 0x6e, 0x12, 0x4a, 0x0a, 0x0a, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x12, + 0x1f, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x57, 0x72, + 0x69, 0x74, 0x65, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1b, 0x2e, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x57, 0x72, 0x69, 0x74, 0x65, 0x4f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, + 0x09, 0x2e, 0x2f, 0x3b, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -1681,12 +1863,12 @@ func file_object_proto_rawDescGZIP() []byte { } var file_object_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_object_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_object_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_object_proto_goTypes = []interface{}{ (WriteObjectResponse_Status)(0), // 0: object.WriteObjectResponse.Status (*GRN)(nil), // 1: object.GRN (*RawObject)(nil), // 2: object.RawObject - (*RawObjectSyncInfo)(nil), // 3: object.RawObjectSyncInfo + (*ObjectOriginInfo)(nil), // 3: object.ObjectOriginInfo (*ObjectErrorInfo)(nil), // 4: object.ObjectErrorInfo (*ObjectVersionInfo)(nil), // 5: object.ObjectVersionInfo (*ReadObjectRequest)(nil), // 6: object.ReadObjectRequest @@ -1694,54 +1876,60 @@ var file_object_proto_goTypes = []interface{}{ (*BatchReadObjectRequest)(nil), // 8: object.BatchReadObjectRequest (*BatchReadObjectResponse)(nil), // 9: object.BatchReadObjectResponse (*WriteObjectRequest)(nil), // 10: object.WriteObjectRequest - (*WriteObjectResponse)(nil), // 11: object.WriteObjectResponse - (*DeleteObjectRequest)(nil), // 12: object.DeleteObjectRequest - (*DeleteObjectResponse)(nil), // 13: object.DeleteObjectResponse - (*ObjectHistoryRequest)(nil), // 14: object.ObjectHistoryRequest - (*ObjectHistoryResponse)(nil), // 15: object.ObjectHistoryResponse - (*ObjectSearchRequest)(nil), // 16: object.ObjectSearchRequest - (*ObjectSearchResult)(nil), // 17: object.ObjectSearchResult - (*ObjectSearchResponse)(nil), // 18: object.ObjectSearchResponse - nil, // 19: object.ObjectSearchRequest.LabelsEntry - nil, // 20: object.ObjectSearchResult.LabelsEntry + (*AdminWriteObjectRequest)(nil), // 11: object.AdminWriteObjectRequest + (*WriteObjectResponse)(nil), // 12: object.WriteObjectResponse + (*DeleteObjectRequest)(nil), // 13: object.DeleteObjectRequest + (*DeleteObjectResponse)(nil), // 14: object.DeleteObjectResponse + (*ObjectHistoryRequest)(nil), // 15: object.ObjectHistoryRequest + (*ObjectHistoryResponse)(nil), // 16: object.ObjectHistoryResponse + (*ObjectSearchRequest)(nil), // 17: object.ObjectSearchRequest + (*ObjectSearchResult)(nil), // 18: object.ObjectSearchResult + (*ObjectSearchResponse)(nil), // 19: object.ObjectSearchResponse + nil, // 20: object.ObjectSearchRequest.LabelsEntry + nil, // 21: object.ObjectSearchResult.LabelsEntry } var file_object_proto_depIdxs = []int32{ 1, // 0: object.RawObject.GRN:type_name -> object.GRN - 3, // 1: object.RawObject.sync:type_name -> object.RawObjectSyncInfo + 3, // 1: object.RawObject.origin:type_name -> object.ObjectOriginInfo 1, // 2: object.ReadObjectRequest.GRN:type_name -> object.GRN 2, // 3: object.ReadObjectResponse.object:type_name -> object.RawObject 6, // 4: object.BatchReadObjectRequest.batch:type_name -> object.ReadObjectRequest 7, // 5: object.BatchReadObjectResponse.results:type_name -> object.ReadObjectResponse 1, // 6: object.WriteObjectRequest.GRN:type_name -> object.GRN - 4, // 7: object.WriteObjectResponse.error:type_name -> object.ObjectErrorInfo - 1, // 8: object.WriteObjectResponse.GRN:type_name -> object.GRN - 5, // 9: object.WriteObjectResponse.object:type_name -> object.ObjectVersionInfo - 0, // 10: object.WriteObjectResponse.status:type_name -> object.WriteObjectResponse.Status - 1, // 11: object.DeleteObjectRequest.GRN:type_name -> object.GRN - 1, // 12: object.ObjectHistoryRequest.GRN:type_name -> object.GRN - 1, // 13: object.ObjectHistoryResponse.GRN:type_name -> object.GRN - 5, // 14: object.ObjectHistoryResponse.versions:type_name -> object.ObjectVersionInfo - 19, // 15: object.ObjectSearchRequest.labels:type_name -> object.ObjectSearchRequest.LabelsEntry - 1, // 16: object.ObjectSearchResult.GRN:type_name -> object.GRN - 20, // 17: object.ObjectSearchResult.labels:type_name -> object.ObjectSearchResult.LabelsEntry - 17, // 18: object.ObjectSearchResponse.results:type_name -> object.ObjectSearchResult - 6, // 19: object.ObjectStore.Read:input_type -> object.ReadObjectRequest - 8, // 20: object.ObjectStore.BatchRead:input_type -> object.BatchReadObjectRequest - 10, // 21: object.ObjectStore.Write:input_type -> object.WriteObjectRequest - 12, // 22: object.ObjectStore.Delete:input_type -> object.DeleteObjectRequest - 14, // 23: object.ObjectStore.History:input_type -> object.ObjectHistoryRequest - 16, // 24: object.ObjectStore.Search:input_type -> object.ObjectSearchRequest - 7, // 25: object.ObjectStore.Read:output_type -> object.ReadObjectResponse - 9, // 26: object.ObjectStore.BatchRead:output_type -> object.BatchReadObjectResponse - 11, // 27: object.ObjectStore.Write:output_type -> object.WriteObjectResponse - 13, // 28: object.ObjectStore.Delete:output_type -> object.DeleteObjectResponse - 15, // 29: object.ObjectStore.History:output_type -> object.ObjectHistoryResponse - 18, // 30: object.ObjectStore.Search:output_type -> object.ObjectSearchResponse - 25, // [25:31] is the sub-list for method output_type - 19, // [19:25] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 1, // 7: object.AdminWriteObjectRequest.GRN:type_name -> object.GRN + 4, // 8: object.WriteObjectResponse.error:type_name -> object.ObjectErrorInfo + 1, // 9: object.WriteObjectResponse.GRN:type_name -> object.GRN + 5, // 10: object.WriteObjectResponse.object:type_name -> object.ObjectVersionInfo + 0, // 11: object.WriteObjectResponse.status:type_name -> object.WriteObjectResponse.Status + 1, // 12: object.DeleteObjectRequest.GRN:type_name -> object.GRN + 1, // 13: object.ObjectHistoryRequest.GRN:type_name -> object.GRN + 1, // 14: object.ObjectHistoryResponse.GRN:type_name -> object.GRN + 5, // 15: object.ObjectHistoryResponse.versions:type_name -> object.ObjectVersionInfo + 20, // 16: object.ObjectSearchRequest.labels:type_name -> object.ObjectSearchRequest.LabelsEntry + 1, // 17: object.ObjectSearchResult.GRN:type_name -> object.GRN + 21, // 18: object.ObjectSearchResult.labels:type_name -> object.ObjectSearchResult.LabelsEntry + 18, // 19: object.ObjectSearchResponse.results:type_name -> object.ObjectSearchResult + 6, // 20: object.ObjectStore.Read:input_type -> object.ReadObjectRequest + 8, // 21: object.ObjectStore.BatchRead:input_type -> object.BatchReadObjectRequest + 10, // 22: object.ObjectStore.Write:input_type -> object.WriteObjectRequest + 13, // 23: object.ObjectStore.Delete:input_type -> object.DeleteObjectRequest + 15, // 24: object.ObjectStore.History:input_type -> object.ObjectHistoryRequest + 17, // 25: object.ObjectStore.Search:input_type -> object.ObjectSearchRequest + 11, // 26: object.ObjectStore.AdminWrite:input_type -> object.AdminWriteObjectRequest + 11, // 27: object.ObjectStoreAdmin.AdminWrite:input_type -> object.AdminWriteObjectRequest + 7, // 28: object.ObjectStore.Read:output_type -> object.ReadObjectResponse + 9, // 29: object.ObjectStore.BatchRead:output_type -> object.BatchReadObjectResponse + 12, // 30: object.ObjectStore.Write:output_type -> object.WriteObjectResponse + 14, // 31: object.ObjectStore.Delete:output_type -> object.DeleteObjectResponse + 16, // 32: object.ObjectStore.History:output_type -> object.ObjectHistoryResponse + 19, // 33: object.ObjectStore.Search:output_type -> object.ObjectSearchResponse + 12, // 34: object.ObjectStore.AdminWrite:output_type -> object.WriteObjectResponse + 12, // 35: object.ObjectStoreAdmin.AdminWrite:output_type -> object.WriteObjectResponse + 28, // [28:36] is the sub-list for method output_type + 20, // [20:28] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_object_proto_init() } @@ -1775,7 +1963,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RawObjectSyncInfo); i { + switch v := v.(*ObjectOriginInfo); i { case 0: return &v.state case 1: @@ -1871,7 +2059,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*WriteObjectResponse); i { + switch v := v.(*AdminWriteObjectRequest); i { case 0: return &v.state case 1: @@ -1883,7 +2071,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteObjectRequest); i { + switch v := v.(*WriteObjectResponse); i { case 0: return &v.state case 1: @@ -1895,7 +2083,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeleteObjectResponse); i { + switch v := v.(*DeleteObjectRequest); i { case 0: return &v.state case 1: @@ -1907,7 +2095,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectHistoryRequest); i { + switch v := v.(*DeleteObjectResponse); i { case 0: return &v.state case 1: @@ -1919,7 +2107,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectHistoryResponse); i { + switch v := v.(*ObjectHistoryRequest); i { case 0: return &v.state case 1: @@ -1931,7 +2119,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectSearchRequest); i { + switch v := v.(*ObjectHistoryResponse); i { case 0: return &v.state case 1: @@ -1943,7 +2131,7 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ObjectSearchResult); i { + switch v := v.(*ObjectSearchRequest); i { case 0: return &v.state case 1: @@ -1955,6 +2143,18 @@ func file_object_proto_init() { } } file_object_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ObjectSearchResult); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_object_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ObjectSearchResponse); i { case 0: return &v.state @@ -1973,9 +2173,9 @@ func file_object_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_object_proto_rawDesc, NumEnums: 1, - NumMessages: 20, + NumMessages: 21, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_object_proto_goTypes, DependencyIndexes: file_object_proto_depIdxs, diff --git a/pkg/services/store/object/object.proto b/pkg/services/store/object/object.proto index 9e69e008bbd..152681bafec 100644 --- a/pkg/services/store/object/object.proto +++ b/pkg/services/store/object/object.proto @@ -53,10 +53,10 @@ message RawObject { string version = 9; // External location info - RawObjectSyncInfo sync = 10; + ObjectOriginInfo origin = 10; } -message RawObjectSyncInfo { +message ObjectOriginInfo { // NOTE: currently managed by the dashboard_provisioning table string source = 1; @@ -156,6 +156,51 @@ message WriteObjectRequest { string previous_version = 4; } +// This operation is useful when syncing a resource from external sources +// that have more accurate metadata information (git, or an archive). +// This process can bypass the forced checks that +message AdminWriteObjectRequest { + // Object identifier + GRN GRN = 1; + + // The raw object body + bytes body = 2; + + // Message that can be seen when exploring object history + string comment = 3; + + // Time in epoch milliseconds that the object was created + // Optional, if 0 it will use the current time + int64 created_at = 4; + + // Time in epoch milliseconds that the object was updated + // Optional, if empty it will use the current user + int64 updated_at = 5; + + // Who created the object + // Optional, if 0 it will use the current time + string created_by = 6; + + // Who updated the object + // Optional, if empty it will use the current user + string updated_by = 7; + + // An explicit version identifier + // Optional, if set, this will overwrite/define an explicit version + string version = 8; + + // Used for optimistic locking. If missing, the previous version will be replaced regardless + // This may not be used along with an explicit version in the request + string previous_version = 9; + + // Request that all previous versions are removed from the history + // This will make sense for systems that manage history explicitly externallay + bool clear_history = 10; + + // Optionally define where the object came from + string origin = 11; +} + message WriteObjectResponse { // Error info -- if exists, the save did not happen ObjectErrorInfo error = 1; @@ -312,8 +357,7 @@ message ObjectSearchResponse { // Storage interface //----------------------------------------------- -// This assumes a future grpc interface where the user info is passed in context, not in each message body -// for now it will only work with an admin API key +// The object store provides a basic CRUD (+watch eventually) interface for generic objects service ObjectStore { rpc Read(ReadObjectRequest) returns (ReadObjectResponse); rpc BatchRead(BatchReadObjectRequest) returns (BatchReadObjectResponse); @@ -325,4 +369,13 @@ service ObjectStore { // Ideally an additional search endpoint with more flexibility to limit what you actually care about // https://github.com/grafana/grafana-plugin-sdk-go/blob/main/proto/backend.proto#L129 // rpc SearchEX(ObjectSearchRequest) returns (DataResponse); + + // TEMPORARY... while we split this into a new service (see below) + rpc AdminWrite(AdminWriteObjectRequest) returns (WriteObjectResponse); +} + +// The admin service extends the basic object store interface, but provides +// more explicit control that can support bulk operations like efficient git sync +service ObjectStoreAdmin { + rpc AdminWrite(AdminWriteObjectRequest) returns (WriteObjectResponse); } diff --git a/pkg/services/store/object/object_grpc.pb.go b/pkg/services/store/object/object_grpc.pb.go index 90f5d51498b..ec546c6786b 100644 --- a/pkg/services/store/object/object_grpc.pb.go +++ b/pkg/services/store/object/object_grpc.pb.go @@ -28,6 +28,8 @@ type ObjectStoreClient interface { Delete(ctx context.Context, in *DeleteObjectRequest, opts ...grpc.CallOption) (*DeleteObjectResponse, error) History(ctx context.Context, in *ObjectHistoryRequest, opts ...grpc.CallOption) (*ObjectHistoryResponse, error) Search(ctx context.Context, in *ObjectSearchRequest, opts ...grpc.CallOption) (*ObjectSearchResponse, error) + // TEMPORARY... while we split this into a new service (see below) + AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) } type objectStoreClient struct { @@ -92,6 +94,15 @@ func (c *objectStoreClient) Search(ctx context.Context, in *ObjectSearchRequest, return out, nil } +func (c *objectStoreClient) AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) { + out := new(WriteObjectResponse) + err := c.cc.Invoke(ctx, "/object.ObjectStore/AdminWrite", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // ObjectStoreServer is the server API for ObjectStore service. // All implementations should embed UnimplementedObjectStoreServer // for forward compatibility @@ -102,6 +113,8 @@ type ObjectStoreServer interface { Delete(context.Context, *DeleteObjectRequest) (*DeleteObjectResponse, error) History(context.Context, *ObjectHistoryRequest) (*ObjectHistoryResponse, error) Search(context.Context, *ObjectSearchRequest) (*ObjectSearchResponse, error) + // TEMPORARY... while we split this into a new service (see below) + AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) } // UnimplementedObjectStoreServer should be embedded to have forward compatible implementations. @@ -126,6 +139,9 @@ func (UnimplementedObjectStoreServer) History(context.Context, *ObjectHistoryReq func (UnimplementedObjectStoreServer) Search(context.Context, *ObjectSearchRequest) (*ObjectSearchResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Search not implemented") } +func (UnimplementedObjectStoreServer) AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AdminWrite not implemented") +} // UnsafeObjectStoreServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ObjectStoreServer will @@ -246,6 +262,24 @@ func _ObjectStore_Search_Handler(srv interface{}, ctx context.Context, dec func( return interceptor(ctx, in, info, handler) } +func _ObjectStore_AdminWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdminWriteObjectRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ObjectStoreServer).AdminWrite(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/object.ObjectStore/AdminWrite", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ObjectStoreServer).AdminWrite(ctx, req.(*AdminWriteObjectRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ObjectStore_ServiceDesc is the grpc.ServiceDesc for ObjectStore service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -277,6 +311,94 @@ var ObjectStore_ServiceDesc = grpc.ServiceDesc{ MethodName: "Search", Handler: _ObjectStore_Search_Handler, }, + { + MethodName: "AdminWrite", + Handler: _ObjectStore_AdminWrite_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "object.proto", +} + +// ObjectStoreAdminClient is the client API for ObjectStoreAdmin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ObjectStoreAdminClient interface { + AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) +} + +type objectStoreAdminClient struct { + cc grpc.ClientConnInterface +} + +func NewObjectStoreAdminClient(cc grpc.ClientConnInterface) ObjectStoreAdminClient { + return &objectStoreAdminClient{cc} +} + +func (c *objectStoreAdminClient) AdminWrite(ctx context.Context, in *AdminWriteObjectRequest, opts ...grpc.CallOption) (*WriteObjectResponse, error) { + out := new(WriteObjectResponse) + err := c.cc.Invoke(ctx, "/object.ObjectStoreAdmin/AdminWrite", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ObjectStoreAdminServer is the server API for ObjectStoreAdmin service. +// All implementations should embed UnimplementedObjectStoreAdminServer +// for forward compatibility +type ObjectStoreAdminServer interface { + AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) +} + +// UnimplementedObjectStoreAdminServer should be embedded to have forward compatible implementations. +type UnimplementedObjectStoreAdminServer struct { +} + +func (UnimplementedObjectStoreAdminServer) AdminWrite(context.Context, *AdminWriteObjectRequest) (*WriteObjectResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AdminWrite not implemented") +} + +// UnsafeObjectStoreAdminServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ObjectStoreAdminServer will +// result in compilation errors. +type UnsafeObjectStoreAdminServer interface { + mustEmbedUnimplementedObjectStoreAdminServer() +} + +func RegisterObjectStoreAdminServer(s grpc.ServiceRegistrar, srv ObjectStoreAdminServer) { + s.RegisterService(&ObjectStoreAdmin_ServiceDesc, srv) +} + +func _ObjectStoreAdmin_AdminWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdminWriteObjectRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ObjectStoreAdminServer).AdminWrite(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/object.ObjectStoreAdmin/AdminWrite", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ObjectStoreAdminServer).AdminWrite(ctx, req.(*AdminWriteObjectRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ObjectStoreAdmin_ServiceDesc is the grpc.ServiceDesc for ObjectStoreAdmin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ObjectStoreAdmin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "object.ObjectStoreAdmin", + HandlerType: (*ObjectStoreAdminServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AdminWrite", + Handler: _ObjectStoreAdmin_AdminWrite_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "object.proto", diff --git a/pkg/services/store/object/sqlstash/sql_storage_server.go b/pkg/services/store/object/sqlstash/sql_storage_server.go index 04a78396ef7..29e23cec334 100644 --- a/pkg/services/store/object/sqlstash/sql_storage_server.go +++ b/pkg/services/store/object/sqlstash/sql_storage_server.go @@ -23,6 +23,10 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +// Make sure we implement both store + admin +var _ object.ObjectStoreServer = &sqlObjectServer{} +var _ object.ObjectStoreAdminServer = &sqlObjectServer{} + func ProvideSQLObjectServer(db db.DB, cfg *setting.Cfg, grpcServerProvider grpcserver.Provider, kinds kind.KindRegistry, resolver resolver.ObjectReferenceResolver) object.ObjectStoreServer { objectServer := &sqlObjectServer{ sess: db.GetSqlxSession(), @@ -49,7 +53,7 @@ func getReadSelect(r *object.ReadObjectRequest) string { "size", "etag", "errors", // errors are always returned "created_at", "created_by", "updated_at", "updated_by", - "sync_src", "sync_time"} + "origin", "origin_ts"} if r.WithBody { fields = append(fields, `body`) @@ -62,8 +66,8 @@ func getReadSelect(r *object.ReadObjectRequest) string { func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql.Rows, r *object.ReadObjectRequest) (*object.ReadObjectResponse, error) { path := "" // string (extract UID?) - var syncSrc sql.NullString - var syncTime sql.NullTime + var origin sql.NullString + originTime := int64(0) raw := &object.RawObject{ GRN: &object.GRN{}, } @@ -74,7 +78,7 @@ func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql &raw.Size, &raw.ETag, &summaryjson.errors, &raw.CreatedAt, &raw.CreatedBy, &raw.UpdatedAt, &raw.UpdatedBy, - &syncSrc, &syncTime, + &origin, &originTime, } if r.WithBody { args = append(args, &raw.Body) @@ -88,10 +92,10 @@ func (s *sqlObjectServer) rowToReadObjectResponse(ctx context.Context, rows *sql return nil, err } - if syncSrc.Valid || syncTime.Valid { - raw.Sync = &object.RawObjectSyncInfo{ - Source: syncSrc.String, - Time: syncTime.Time.UnixMilli(), + if origin.Valid { + raw.Origin = &object.ObjectOriginInfo{ + Source: origin.String, + Time: originTime, } } @@ -273,6 +277,11 @@ func (s *sqlObjectServer) BatchRead(ctx context.Context, b *object.BatchReadObje } func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectRequest) (*object.WriteObjectResponse, error) { + return s.AdminWrite(ctx, object.ToAdminWriteObjectRequest(r)) +} + +//nolint:gocyclo +func (s *sqlObjectServer) AdminWrite(ctx context.Context, r *object.AdminWriteObjectRequest) (*object.WriteObjectResponse, error) { route, err := s.getObjectKey(ctx, r.GRN) if err != nil { return nil, err @@ -282,9 +291,20 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques return nil, fmt.Errorf("invalid grn") } - modifier := store.UserFromContext(ctx) - if modifier == nil { - return nil, fmt.Errorf("can not find user in context") + timestamp := time.Now().UnixMilli() + createdAt := r.CreatedAt + createdBy := r.CreatedBy + updatedAt := r.UpdatedAt + updatedBy := r.UpdatedBy + if updatedBy == "" { + modifier := store.UserFromContext(ctx) + if modifier == nil { + return nil, fmt.Errorf("can not find user in context") + } + updatedBy = store.GetUserIDString(modifier) + } + if updatedAt < 1000 { + updatedAt = timestamp } summary, body, err := s.prepare(ctx, r) @@ -309,10 +329,26 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques } err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { + var versionInfo *object.ObjectVersionInfo isUpdate := false - versionInfo, err := s.selectForUpdate(ctx, tx, path) - if err != nil { - return err + if r.ClearHistory { + // Optionally keep the original creation time information + if createdAt < 1000 || createdBy == "" { + err = s.fillCreationInfo(ctx, tx, path, &createdAt, &createdBy) + if err != nil { + return err + } + } + _, err = doDelete(ctx, tx, path) + if err != nil { + return err + } + versionInfo = &object.ObjectVersionInfo{} + } else { + versionInfo, err = s.selectForUpdate(ctx, tx, path) + if err != nil { + return err + } } // Same object @@ -330,18 +366,21 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques } // Set the comment on this write - timestamp := time.Now().UnixMilli() versionInfo.Comment = r.Comment - if versionInfo.Version == "" { - versionInfo.Version = "1" - } else { - // Increment the version - i, _ := strconv.ParseInt(versionInfo.Version, 0, 64) - if i < 1 { - i = timestamp + if r.Version == "" { + if versionInfo.Version == "" { + versionInfo.Version = "1" + } else { + // Increment the version + i, _ := strconv.ParseInt(versionInfo.Version, 0, 64) + if i < 1 { + i = timestamp + } + versionInfo.Version = fmt.Sprintf("%d", i+1) + isUpdate = true } - versionInfo.Version = fmt.Sprintf("%d", i+1) - isUpdate = true + } else { + versionInfo.Version = r.Version } if isUpdate { @@ -357,8 +396,8 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques // 1. Add the `object_history` values versionInfo.Size = int64(len(body)) versionInfo.ETag = etag - versionInfo.UpdatedAt = timestamp - versionInfo.UpdatedBy = store.GetUserIDString(modifier) + versionInfo.UpdatedAt = updatedAt + versionInfo.UpdatedBy = updatedBy _, err = tx.Exec(ctx, `INSERT INTO object_history (`+ "path, version, message, "+ "size, body, etag, "+ @@ -366,7 +405,7 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", path, versionInfo.Version, versionInfo.Comment, versionInfo.Size, body, versionInfo.ETag, - timestamp, versionInfo.UpdatedBy, + updatedAt, versionInfo.UpdatedBy, ) if err != nil { return err @@ -411,27 +450,35 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques "body=?, size=?, etag=?, version=?, "+ "updated_at=?, updated_by=?,"+ "name=?, description=?,"+ - "labels=?, fields=?, errors=? "+ + "labels=?, fields=?, errors=?, "+ + "origin=?, origin_ts=? "+ "WHERE path=?", body, versionInfo.Size, etag, versionInfo.Version, - timestamp, versionInfo.UpdatedBy, + updatedAt, versionInfo.UpdatedBy, summary.model.Name, summary.model.Description, summary.labels, summary.fields, summary.errors, + r.Origin, timestamp, path, ) return err } - // Insert the new row + if createdAt < 1000 { + createdAt = updatedAt + } + if createdBy == "" { + createdBy = updatedBy + } + _, err = tx.Exec(ctx, "INSERT INTO object ("+ - "path, parent_folder_path, kind, size, body, etag, version,"+ - "updated_at, updated_by, created_at, created_by,"+ - "name, description,"+ + "path, parent_folder_path, kind, size, body, etag, version, "+ + "updated_at, updated_by, created_at, created_by, "+ + "name, description, origin, origin_ts, "+ "labels, fields, errors) "+ - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", path, getParentFolderPath(grn.Kind, path), grn.Kind, versionInfo.Size, body, etag, versionInfo.Version, - timestamp, versionInfo.UpdatedBy, timestamp, versionInfo.UpdatedBy, // created + updated are the same - summary.model.Name, summary.model.Description, + updatedAt, createdBy, createdAt, createdBy, // created + updated are the same + summary.model.Name, summary.model.Description, r.Origin, timestamp, summary.labels, summary.fields, summary.errors, ) return err @@ -443,6 +490,28 @@ func (s *sqlObjectServer) Write(ctx context.Context, r *object.WriteObjectReques return rsp, err } +func (s *sqlObjectServer) fillCreationInfo(ctx context.Context, tx *session.SessionTx, path string, createdAt *int64, createdBy *string) error { + if *createdAt > 1000 { + ignore := int64(0) + createdAt = &ignore + } + if *createdBy == "" { + ignore := "" + createdBy = &ignore + } + + rows, err := tx.Query(ctx, "SELECT created_at,created_by FROM object WHERE path=?", path) + if err == nil { + if rows.Next() { + err = rows.Scan(&createdAt, &createdBy) + } + if err == nil { + err = rows.Close() + } + } + return err +} + func (s *sqlObjectServer) selectForUpdate(ctx context.Context, tx *session.SessionTx, path string) (*object.ObjectVersionInfo, error) { q := "SELECT etag,version,updated_at,size FROM object WHERE path=?" if false { // TODO, MYSQL/PosgreSQL can lock the row " FOR UPDATE" @@ -462,7 +531,7 @@ func (s *sqlObjectServer) selectForUpdate(ctx context.Context, tx *session.Sessi return current, err } -func (s *sqlObjectServer) prepare(ctx context.Context, r *object.WriteObjectRequest) (*summarySupport, []byte, error) { +func (s *sqlObjectServer) prepare(ctx context.Context, r *object.AdminWriteObjectRequest) (*summarySupport, []byte, error) { grn := r.GRN builder := s.kinds.GetSummaryBuilder(grn.Kind) if builder == nil { @@ -490,27 +559,29 @@ func (s *sqlObjectServer) Delete(ctx context.Context, r *object.DeleteObjectRequ rsp := &object.DeleteObjectResponse{} err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error { - results, err := tx.Exec(ctx, "DELETE FROM object WHERE path=?", path) - if err != nil { - return err - } - rows, err := results.RowsAffected() - if err != nil { - return err - } - if rows > 0 { - rsp.OK = true - } - - // TODO: keep history? would need current version bump, and the "write" would have to get from history - _, _ = tx.Exec(ctx, "DELETE FROM object_history WHERE path=?", path) - _, _ = tx.Exec(ctx, "DELETE FROM object_labels WHERE path=?", path) - _, _ = tx.Exec(ctx, "DELETE FROM object_ref WHERE path=?", path) - return nil + rsp.OK, err = doDelete(ctx, tx, path) + return err }) return rsp, err } +func doDelete(ctx context.Context, tx *session.SessionTx, path string) (bool, error) { + results, err := tx.Exec(ctx, "DELETE FROM object WHERE path=?", path) + if err != nil { + return false, err + } + rows, err := results.RowsAffected() + if err != nil { + return false, err + } + + // TODO: keep history? would need current version bump, and the "write" would have to get from history + _, _ = tx.Exec(ctx, "DELETE FROM object_history WHERE path=?", path) + _, _ = tx.Exec(ctx, "DELETE FROM object_labels WHERE path=?", path) + _, _ = tx.Exec(ctx, "DELETE FROM object_ref WHERE path=?", path) + return rows > 0, err +} + func (s *sqlObjectServer) History(ctx context.Context, r *object.ObjectHistoryRequest) (*object.ObjectHistoryResponse, error) { route, err := s.getObjectKey(ctx, r.GRN) if err != nil { diff --git a/pkg/services/store/object/utils.go b/pkg/services/store/object/utils.go new file mode 100644 index 00000000000..9aac9eb8e29 --- /dev/null +++ b/pkg/services/store/object/utils.go @@ -0,0 +1,11 @@ +package object + +// The admin request is a superset of write request features +func ToAdminWriteObjectRequest(req *WriteObjectRequest) *AdminWriteObjectRequest { + return &AdminWriteObjectRequest{ + GRN: req.GRN, + Body: req.Body, + Comment: req.Comment, + PreviousVersion: req.PreviousVersion, + } +} From 75c9350a5a905b4141e5f8dfadb79a77e5845078 Mon Sep 17 00:00:00 2001 From: Niklas Kaaf Date: Sun, 13 Nov 2022 13:43:23 +0100 Subject: [PATCH 216/926] docs: fix heading level for env variable on configuration page (#58689) fix syntax error in Heading --- 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 80c5336a2a1..0cd2970e338 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -557,7 +557,7 @@ Default is `admin`. The password of the default Grafana Admin. Set once on first-run. Default is `admin`. -# admin_email +### admin_email The email of the default Grafana Admin, created on startup. Default is `admin@localhost`. From ce5040074058062e1777feafe2ad419af1f6a216 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 14 Nov 2022 09:30:52 +0100 Subject: [PATCH 217/926] Toolkit: Fix compilation loop when watching plugins for changes (#58167) * fix(toolkit): ignore node_modules and dist directories when watching for changes to plugin * fix(toolkit): move watchOptions.ignored config to pluginDev watch call --- packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts | 3 +-- packages/grafana-toolkit/src/config/webpack.plugin.config.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts index 7d8b433403c..9743f402a63 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts @@ -22,9 +22,8 @@ export const bundlePlugin = async ({ watch, production, preserveConsole }: Plugi const webpackPromise = new Promise((resolve, reject) => { if (watch) { console.log('Started watching plugin for changes...'); - compiler.watch({}, (err, stats) => {}); + compiler.watch({ ignored: ['**/node_modules', '**/dist'] }, (err, stats) => {}); - // @ts-ignore compiler.hooks.invalid.tap('invalid', () => { clearConsole(); console.log('Compiling...'); diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 7ba2d217d34..382c1e0b0a9 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -162,7 +162,6 @@ const getBaseWebpackConfig: WebpackConfigurationGetter = async (options) => { libraryTarget: 'amd', publicPath: '/', }, - performance: { hints: false }, externals: [ 'lodash', From f4531b4ee18e56fad9b046985c821d0e24f3a651 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Mon, 14 Nov 2022 09:42:31 +0100 Subject: [PATCH 218/926] Omit error from http response (#58443) --- pkg/api/password.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/api/password.go b/pkg/api/password.go index 8496c71b305..fe41c9ca5fa 100644 --- a/pkg/api/password.go +++ b/pkg/api/password.go @@ -28,8 +28,8 @@ func (hs *HTTPServer) SendResetPasswordEmail(c *models.ReqContext) response.Resp usr, err := hs.userService.GetByLogin(c.Req.Context(), &userQuery) if err != nil { - c.Logger.Info("Requested password reset for user that was not found", "user", userQuery.LoginOrEmail) - return response.Error(http.StatusOK, "Email sent", err) + c.Logger.Info("Requested password reset for user that was not found", "user", userQuery.LoginOrEmail, "error", err) + return response.Error(http.StatusOK, "Email sent", nil) } if usr.IsDisabled { From a71f74220a8abd932302ea8aa496b6fab301a0d4 Mon Sep 17 00:00:00 2001 From: Leo <108552997+lpskdl@users.noreply.github.com> Date: Mon, 14 Nov 2022 10:01:23 +0100 Subject: [PATCH 219/926] Navigation: Remove monitoring texts for items under Monitoring section (#58522) * rename Synthetic monitoring with Synthetics * improved text override --- pkg/services/navtree/navtreeimpl/applinks.go | 12 ++++++++---- pkg/services/navtree/navtreeimpl/navtree.go | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index f103b336525..0366fdd6dc7 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -176,6 +176,10 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo if navConfig, hasOverride := s.navigationAppConfig[plugin.ID]; hasOverride { appLink.SortWeight = navConfig.SortWeight sectionID = navConfig.SectionID + + if len(navConfig.Text) > 0 { + appLink.Text = navConfig.Text + } } if navNode := treeRoot.FindById(sectionID); navNode != nil { @@ -228,10 +232,10 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo func (s *ServiceImpl) readNavigationSettings() { s.navigationAppConfig = map[string]NavigationAppConfig{ - "grafana-k8s-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 1}, - "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2}, - "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1}, - "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2}, + "grafana-k8s-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 1, Text: "Kubernetes"}, + "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2, Text: "Synthetics"}, + "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"}, + "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incident"}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, } diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 3dca0dbf63b..94ed6c125b4 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -44,6 +44,7 @@ type ServiceImpl struct { type NavigationAppConfig struct { SectionID string SortWeight int64 + Text string } func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service, queryLibraryService querylibrary.HTTPService) navtree.Service { From c3fef96ee077e31617f29035b826a607f652b237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Labesse=20K=C3=A9vin?= Date: Mon, 14 Nov 2022 10:35:44 +0100 Subject: [PATCH 220/926] docs: code format (#58216) --- .../terraform-provisioning/index.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md index a448cd1fb26..19e58c86957 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/terraform-provisioning/index.md @@ -139,9 +139,10 @@ In this example, the alerts are grouped by `alertname`, which means that any not If you want to route specific notifications differently, you can add sub-policies. Sub-policies allow you to apply routing to different alerts based on label matching. In this example, we apply a mute timing to all alerts with the label a=b. +```terraform resource "grafana_notification_policy" "my_policy" { -group_by = ["alertname"] -contact_point = grafana_contact_point.my_slack_contact_point.name + group_by = ["alertname"] + contact_point = grafana_contact_point.my_slack_contact_point.name group_wait = "45s" group_interval = "6m" @@ -167,8 +168,8 @@ contact_point = grafana_contact_point.my_slack_contact_point.name group_by = ["..."] } } - } +``` 2. In the mute_timings field, link a mute timing to your notification policy. @@ -192,8 +193,9 @@ To provision mute timings, complete the following steps. In this example, alert notifications are muted on weekends. +```terraform resource "grafana_mute_timing" "my_mute_timing" { -name = "My Mute Timing" + name = "My Mute Timing" intervals { times { @@ -204,8 +206,8 @@ name = "My Mute Timing" months = ["january:march", "12"] years = ["2025:2027"] } - } +``` 2. Run the command ‘terraform apply’. 3. Go to the Grafana UI and check the details of your mute timing. From b0c197b966e9d506c8f6120ab926e0b900bb2c36 Mon Sep 17 00:00:00 2001 From: Garrett Guillotte <100453168+gguillotte-grafana@users.noreply.github.com> Date: Mon, 14 Nov 2022 01:49:04 -0800 Subject: [PATCH 221/926] Docs: Comment out broken images (#57482) * Docs: Comment out broken images * Docs: Hide the correct images Co-authored-by: Jack Baldry Co-authored-by: Daniel Lee --- .../build-dashboards/annotate-visualizations/index.md | 4 ++-- docs/sources/dashboards/create-reports/index.md | 4 +++- docs/sources/dashboards/manage-dashboards/index.md | 3 ++- docs/sources/datasources/zipkin/_index.md | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md b/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md index 2b09b1a1451..9b3144832af 100644 --- a/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md +++ b/docs/sources/dashboards/build-dashboards/annotate-visualizations/index.md @@ -45,7 +45,7 @@ Alternatively, to add an annotation, Ctrl/Cmd+Click on the Time series panel and ### Edit annotation 1. In the dashboard hover over an annotation indicator on the Time series panel. - ![Add annotation popover](/static/img/docs/time-series-panel/time-series-annotations-edit-annotation.gif) + 1. Click on the pencil icon in the annotation tooltip. 1. Modify the description and/or tags. 1. Click save. @@ -53,7 +53,7 @@ Alternatively, to add an annotation, Ctrl/Cmd+Click on the Time series panel and ### Delete annotation 1. In the dashboard hover over an annotation indicator on the Time series panel. - ![Add annotation popover](/static/img/docs/time-series-panel/time-series-annotations-edit-annotation.gif) + 1. Click on the trash icon in the annotation tooltip. ### Built-in query diff --git a/docs/sources/dashboards/create-reports/index.md b/docs/sources/dashboards/create-reports/index.md index 2fed7c32274..03e1e7d98ae 100644 --- a/docs/sources/dashboards/create-reports/index.md +++ b/docs/sources/dashboards/create-reports/index.md @@ -24,7 +24,9 @@ Reporting enables you to automatically generate PDFs from any of your dashboards > If you have [Role-based access control]({{< relref "../../administration/roles-and-permissions/access-control/" >}}) enabled, for some actions you would need to have relevant permissions. > Refer to specific guides to understand what permissions are required. -{{< figure src="/static/img/docs/enterprise/reports_list_8.1.png" max-width="500px" class="docs-image--no-shadow" >}} + Any changes you make to a dashboard used in a report are reflected the next time the report is sent. For example, if you change the time range in the dashboard, then the time range in the report also changes. diff --git a/docs/sources/dashboards/manage-dashboards/index.md b/docs/sources/dashboards/manage-dashboards/index.md index c8b61bfc114..5a2e19566d8 100644 --- a/docs/sources/dashboards/manage-dashboards/index.md +++ b/docs/sources/dashboards/manage-dashboards/index.md @@ -119,7 +119,8 @@ A template variable of the type `Constant` will automatically be hidden in the d - Paste a [Grafana.com](https://grafana.com) dashboard URL - Paste dashboard JSON text directly into the text area -{{< figure src="/static/img/docs/v70/import_step2_grafana.com.png" max-width="700px" >}} + The import process enables you to change the name of the dashboard, pick the data source you want the dashboard to use, and specify any metric prefixes (if the dashboard uses any). diff --git a/docs/sources/datasources/zipkin/_index.md b/docs/sources/datasources/zipkin/_index.md index d104ad7753a..941d9390841 100644 --- a/docs/sources/datasources/zipkin/_index.md +++ b/docs/sources/datasources/zipkin/_index.md @@ -50,7 +50,9 @@ Set the data source's basic configuration options carefully: The **Trace to logs** section configures the [trace to logs feature]({{< relref "../../explore/trace-integration/" >}}). Select a target data source, limited to Loki and Splunk \[logs\] data sources, and which tags to use in the logs query. + | Name | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From 09c3ead94573815e089a99caa8aaf536dd83193b Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Mon, 14 Nov 2022 11:14:50 +0100 Subject: [PATCH 222/926] CI: Make build and store storybook trigger in the release process (#58686) Make storybook trigger conditional --- .drone.yml | 12 +++++------- scripts/drone/steps/lib.star | 27 ++++++++++++++++++--------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/.drone.yml b/.drone.yml index f3b3d4565e6..1be3ebcab5b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2132,9 +2132,8 @@ steps: image: grafana/build-container:1.6.5 name: build-storybook when: - paths: - include: - - packages/grafana-ui/** + event: + - tag - commands: - ./bin/grabpl upload-cdn --edition oss depends_on: @@ -2177,9 +2176,8 @@ steps: image: grafana/grafana-ci-deploy:1.3.3 name: store-storybook when: - paths: - include: - - packages/grafana-ui/** + event: + - tag - commands: - ./bin/grabpl artifacts npm store --tag ${DRONE_TAG} depends_on: @@ -5514,6 +5512,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 1d42ccac383b4cacb1a626ffdc71847208cca3b464a5ba80e012703b47d2b347 +hmac: 4f5e09af0ec5a9d59c5e31333bf180dd52cba1ad2780d96a62d20583113ccb16 ... diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 0e430dfbc67..0fc7f6f1487 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -16,13 +16,6 @@ trigger_oss = { 'grafana/grafana', ] } -trigger_storybook = { - 'paths': { - 'include': [ - 'packages/grafana-ui/**', - ], - } -} def slack_step(channel, template, secret): @@ -259,7 +252,7 @@ def build_storybook_step(edition, ver_mode): 'yarn storybook:build', './bin/grabpl verify-storybook', ], - 'when': trigger_storybook, + 'when': get_trigger_storybook(ver_mode), } @@ -287,7 +280,7 @@ def store_storybook_step(edition, ver_mode, trigger=None): 'PRERELEASE_BUCKET': from_secret(prerelease_bucket) }, 'commands': commands, - 'when': trigger_storybook, + 'when': get_trigger_storybook(ver_mode), } if trigger and ver_mode in ("release-branch", "main"): # no dict merge operation available, https://github.com/harness/drone-cli/pull/220 @@ -1282,3 +1275,19 @@ def compile_build_cmd(edition='oss'): 'CGO_ENABLED': 0, }, } + +def get_trigger_storybook(ver_mode): + trigger_storybook = '' + if ver_mode == 'release': + trigger_storybook = { + 'event': ['tag'] + } + else: + trigger_storybook = { + 'paths': { + 'include': [ + 'packages/grafana-ui/**', + ], + } + } + return trigger_storybook From 1fddd9aed165f0489c6fe8b155f4214b2cb6ddad Mon Sep 17 00:00:00 2001 From: David Beitey Date: Mon, 14 Nov 2022 20:42:23 +1000 Subject: [PATCH 223/926] Docs: Update install guides link in README (#56116) This updates the link to the installation guides in the README to the docs for the latest Grafana version, whereas the previous redirect was going to v9.0. This also improves the security of the link, which was previously insecure http://. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e2299d522d..f83c1545bb9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Grafana allows you to query, visualize, alert on and understand your metrics no ## Get started - [Get Grafana](https://grafana.com/get) -- [Installation guides](http://docs.grafana.org/installation/) +- [Installation guides](https://grafana.com/docs/grafana/latest/setup-grafana/installation/) Unsure if Grafana is for you? Watch Grafana in action on [play.grafana.org](https://play.grafana.org/)! From 121631daaefec1b3409685e6f2d876eedcf7a636 Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 14 Nov 2022 12:11:26 +0000 Subject: [PATCH 224/926] Fix: Email and username trimming and invitation validation (#58442) * fix: email and username trimming and invitation validation * Trim leading and trailing whitespaces from email and username on signup * Check whether the provided email address is the same as where the invitation sent * Align tests Co-authored-by: Mihaly Gyongyosi --- pkg/api/admin_users.go | 5 + pkg/api/org_invite.go | 27 +++- pkg/api/signup.go | 14 ++- pkg/api/user.go | 39 +++++- pkg/api/user_test.go | 117 ++++++++++++++++++ pkg/api/utils.go | 18 ++- pkg/services/login/logintest/logintest.go | 2 + .../app/core/components/Signup/SignupPage.tsx | 3 +- .../core/components/Signup/VerifyEmail.tsx | 3 +- public/app/features/admin/UserProfile.tsx | 8 +- public/app/features/admin/utils.ts | 4 + public/app/features/invites/SignupInvited.tsx | 4 +- 12 files changed, 229 insertions(+), 15 deletions(-) diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 7daf81be95c..525e42c9c78 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "golang.org/x/sync/errgroup" @@ -40,6 +41,10 @@ func (hs *HTTPServer) AdminCreateUser(c *models.ReqContext) response.Response { if err := web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } + + form.Email = strings.TrimSpace(form.Email) + form.Login = strings.TrimSpace(form.Login) + cmd := user.CreateUserCommand{ Login: form.Login, Email: form.Email, diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go index 53ff2490e8d..0f4eecc4522 100644 --- a/pkg/api/org_invite.go +++ b/pkg/api/org_invite.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" @@ -217,21 +218,37 @@ func (hs *HTTPServer) GetInviteInfoByCode(c *models.ReqContext) response.Respons func (hs *HTTPServer) CompleteInvite(c *models.ReqContext) response.Response { completeInvite := dtos.CompleteInviteForm{} - if err := web.Bind(c.Req, &completeInvite); err != nil { + var err error + if err = web.Bind(c.Req, &completeInvite); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - query := models.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} + completeInvite.Email, err = ValidateAndNormalizeEmail(completeInvite.Email) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid email address provided", nil) + } + + completeInvite.Username = strings.TrimSpace(completeInvite.Username) + + query := models.GetTempUserByCodeQuery{Code: completeInvite.InviteCode} if err := hs.tempUserService.GetTempUserByCode(c.Req.Context(), &query); err != nil { if errors.Is(err, models.ErrTempUserNotFound) { - return response.Error(404, "Invite not found", nil) + return response.Error(http.StatusNotFound, "Invite not found", nil) } - return response.Error(500, "Failed to get invite", err) + return response.Error(http.StatusInternalServerError, "Failed to get invite", err) } invite := query.Result if invite.Status != models.TmpUserInvitePending { - return response.Error(412, fmt.Sprintf("Invite cannot be used in status %s", invite.Status), nil) + return response.Error(http.StatusPreconditionFailed, fmt.Sprintf("Invite cannot be used in status %s", invite.Status), nil) + } + + // In case the user is invited by email address + if inviteMail, err := ValidateAndNormalizeEmail(invite.Email); err == nil { + // Make sure that the email address is not amended + if completeInvite.Email != inviteMail { + return response.Error(http.StatusBadRequest, "The provided email is different from the address that is found in the invite", nil) + } } cmd := user.CreateUserCommand{ diff --git a/pkg/api/signup.go b/pkg/api/signup.go index 37b7ca407e5..c1cd5b5bd57 100644 --- a/pkg/api/signup.go +++ b/pkg/api/signup.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" @@ -27,15 +28,21 @@ func GetSignUpOptions(c *models.ReqContext) response.Response { // POST /api/user/signup func (hs *HTTPServer) SignUp(c *models.ReqContext) response.Response { form := dtos.SignUpForm{} - if err := web.Bind(c.Req, &form); err != nil { + var err error + if err = web.Bind(c.Req, &form); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } if !setting.AllowUserSignUp { return response.Error(401, "User signup is disabled", nil) } + form.Email, err = ValidateAndNormalizeEmail(form.Email) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid email address", nil) + } + existing := user.GetUserByLoginQuery{LoginOrEmail: form.Email} - _, err := hs.userService.GetByLogin(c.Req.Context(), &existing) + _, err = hs.userService.GetByLogin(c.Req.Context(), &existing) if err == nil { return response.Error(422, "User with same email address already exists", nil) } @@ -76,6 +83,9 @@ func (hs *HTTPServer) SignUpStep2(c *models.ReqContext) response.Response { return response.Error(401, "User signup is disabled", nil) } + form.Email = strings.TrimSpace(form.Email) + form.Username = strings.TrimSpace(form.Username) + createUserCmd := user.CreateUserCommand{ Email: form.Email, Login: form.Username, diff --git a/pkg/api/user.go b/pkg/api/user.go index fbce4f3a51b..15c6fa586a4 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" @@ -118,9 +119,14 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Respo // 500: internalServerError func (hs *HTTPServer) UpdateSignedInUser(c *models.ReqContext) response.Response { cmd := user.UpdateUserCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { + var err error + if err = web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } + + cmd.Email = strings.TrimSpace(cmd.Email) + cmd.Login = strings.TrimSpace(cmd.Login) + if setting.AuthProxyEnabled { if setting.AuthProxyHeaderProperty == "email" && cmd.Email != c.Email { return response.Error(400, "Not allowed to change email when auth proxy is using email property", nil) @@ -148,13 +154,18 @@ func (hs *HTTPServer) UpdateSignedInUser(c *models.ReqContext) response.Response func (hs *HTTPServer) UpdateUser(c *models.ReqContext) response.Response { cmd := user.UpdateUserCommand{} var err error - if err := web.Bind(c.Req, &cmd); err != nil { + if err = web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } + + cmd.Email = strings.TrimSpace(cmd.Email) + cmd.Login = strings.TrimSpace(cmd.Login) + cmd.UserID, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "id is invalid", err) } + return hs.handleUpdateUser(c.Req.Context(), cmd) } @@ -183,6 +194,16 @@ func (hs *HTTPServer) UpdateUserActiveOrg(c *models.ReqContext) response.Respons } func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserCommand) response.Response { + // external user -> user data cannot be updated + isExternal, err := hs.isExternalUser(ctx, cmd.UserID) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to validate User", err) + } + + if isExternal { + return response.Error(http.StatusForbidden, "User info cannot be updated for external Users", nil) + } + if len(cmd.Login) == 0 { cmd.Login = cmd.Email if len(cmd.Login) == 0 { @@ -200,6 +221,20 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC return response.Success("User updated") } +func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, error) { + getAuthQuery := models.GetAuthInfoQuery{UserId: userID} + var err error + if err = hs.authInfoService.GetAuthInfo(ctx, &getAuthQuery); err == nil { + return true, nil + } + + if errors.Is(err, user.ErrUserNotFound) { + return false, nil + } + + return false, err +} + // swagger:route GET /user/orgs signed_in_user getSignedInUserOrgList // // Organizations of the actual User. diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 0998a71687c..cc3e8946e5e 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/usagestats" @@ -20,6 +22,7 @@ import ( acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" + "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/secrets/database" @@ -196,3 +199,117 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { assert.Equal(t, 10, respJSON.Get("perPage").MustInt()) }, mock) } + +func TestHTTPServer_UpdateUser(t *testing.T) { + settings := setting.NewCfg() + sqlStore := db.InitTestDB(t) + + hs := &HTTPServer{ + Cfg: settings, + SQLStore: sqlStore, + AccessControl: acmock.New(), + } + + updateUserCommand := user.UpdateUserCommand{ + Email: fmt.Sprint("admin", "@test.com"), + Name: "admin", + Login: "admin", + UserID: 1, + } + + updateUserScenario(t, updateUserContext{ + desc: "Should return 403 when the current User is an external user", + url: "/api/users/1", + routePattern: "/api/users/:id", + cmd: updateUserCommand, + fn: func(sc *scenarioContext) { + sc.authInfoService.ExpectedUserAuth = &models.UserAuth{} + sc.fakeReqWithParams("PUT", sc.url, map[string]string{"id": "1"}).exec() + assert.Equal(t, 403, sc.resp.Code) + }, + }, hs) +} + +type updateUserContext struct { + desc string + url string + routePattern string + cmd user.UpdateUserCommand + fn scenarioFunc +} + +func updateUserScenario(t *testing.T, ctx updateUserContext, hs *HTTPServer) { + t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) { + sc := setupScenarioContext(t, ctx.url) + + sc.authInfoService = &logintest.AuthInfoServiceFake{} + hs.authInfoService = sc.authInfoService + + sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + c.Req.Body = mockRequestBody(ctx.cmd) + c.Req.Header.Add("Content-Type", "application/json") + sc.context = c + sc.context.OrgID = testOrgID + sc.context.UserID = testUserID + + return hs.UpdateUser(c) + }) + + sc.m.Put(ctx.routePattern, sc.defaultHandler) + + ctx.fn(sc) + }) +} + +func TestHTTPServer_UpdateSignedInUser(t *testing.T) { + settings := setting.NewCfg() + sqlStore := db.InitTestDB(t) + + hs := &HTTPServer{ + Cfg: settings, + SQLStore: sqlStore, + AccessControl: acmock.New(), + } + + updateUserCommand := user.UpdateUserCommand{ + Email: fmt.Sprint("admin", "@test.com"), + Name: "admin", + Login: "admin", + UserID: 1, + } + + updateSignedInUserScenario(t, updateUserContext{ + desc: "Should return 403 when the current User is an external user", + url: "/api/users/", + routePattern: "/api/users/", + cmd: updateUserCommand, + fn: func(sc *scenarioContext) { + sc.authInfoService.ExpectedUserAuth = &models.UserAuth{} + sc.fakeReqWithParams("PUT", sc.url, map[string]string{"id": "1"}).exec() + assert.Equal(t, 403, sc.resp.Code) + }, + }, hs) +} + +func updateSignedInUserScenario(t *testing.T, ctx updateUserContext, hs *HTTPServer) { + t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) { + sc := setupScenarioContext(t, ctx.url) + + sc.authInfoService = &logintest.AuthInfoServiceFake{} + hs.authInfoService = sc.authInfoService + + sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { + c.Req.Body = mockRequestBody(ctx.cmd) + c.Req.Header.Add("Content-Type", "application/json") + sc.context = c + sc.context.OrgID = testOrgID + sc.context.UserID = testUserID + + return hs.UpdateSignedInUser(c) + }) + + sc.m.Put(ctx.routePattern, sc.defaultHandler) + + ctx.fn(sc) + }) +} diff --git a/pkg/api/utils.go b/pkg/api/utils.go index d673b03af1b..716e8b98b60 100644 --- a/pkg/api/utils.go +++ b/pkg/api/utils.go @@ -1,9 +1,25 @@ package api -import "encoding/json" +import ( + "encoding/json" + "net/mail" +) func jsonMap(data []byte) (map[string]string, error) { jsonMap := make(map[string]string) err := json.Unmarshal(data, &jsonMap) return jsonMap, err } + +func ValidateAndNormalizeEmail(email string) (string, error) { + if email == "" { + return "", nil + } + + e, err := mail.ParseAddress(email) + if err != nil { + return "", err + } + + return e.Address, nil +} diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go index d4a9e37c3c6..5c2ce4005df 100644 --- a/pkg/services/login/logintest/logintest.go +++ b/pkg/services/login/logintest/logintest.go @@ -23,6 +23,7 @@ func (l *LoginServiceFake) SetTeamSyncFunc(login.TeamSyncFunc) {} type AuthInfoServiceFake struct { LatestUserID int64 + ExpectedUserAuth *models.UserAuth ExpectedUser *user.User ExpectedExternalUser *models.ExternalUserInfo ExpectedError error @@ -39,6 +40,7 @@ func (a *AuthInfoServiceFake) LookupAndUpdate(ctx context.Context, query *models func (a *AuthInfoServiceFake) GetAuthInfo(ctx context.Context, query *models.GetAuthInfoQuery) error { a.LatestUserID = query.UserId + query.Result = a.ExpectedUserAuth return a.ExpectedError } diff --git a/public/app/core/components/Signup/SignupPage.tsx b/public/app/core/components/Signup/SignupPage.tsx index 3d6a6188475..c757bb753fe 100644 --- a/public/app/core/components/Signup/SignupPage.tsx +++ b/public/app/core/components/Signup/SignupPage.tsx @@ -5,6 +5,7 @@ import { Form, Field, Input, Button, HorizontalGroup, LinkButton, FormAPI } from import { getConfig } from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { w3cStandardEmailValidator } from 'app/features/admin/utils'; import { InnerBox, LoginLayout } from '../Login/LoginLayout'; import { PasswordField } from '../PasswordField/PasswordField'; @@ -74,7 +75,7 @@ export const SignupPage: FC = (props) => { {...register('email', { required: 'Email is required', pattern: { - value: /^\S+@\S+$/, + value: w3cStandardEmailValidator, message: 'Email is invalid', }, })} diff --git a/public/app/core/components/Signup/VerifyEmail.tsx b/public/app/core/components/Signup/VerifyEmail.tsx index cbb2b7fc7f3..e372217e06c 100644 --- a/public/app/core/components/Signup/VerifyEmail.tsx +++ b/public/app/core/components/Signup/VerifyEmail.tsx @@ -4,6 +4,7 @@ import { getBackendSrv } from '@grafana/runtime'; import { Form, Field, Input, Button, Legend, Container, HorizontalGroup, LinkButton } from '@grafana/ui'; import { getConfig } from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; +import { w3cStandardEmailValidator } from 'app/features/admin/utils'; interface EmailDTO { email: string; @@ -53,7 +54,7 @@ export const VerifyEmail = () => { {...register('email', { required: 'Email is required', pattern: { - value: /^\S+@\S+$/, + value: w3cStandardEmailValidator, message: 'Email is invalid', }, })} diff --git a/public/app/features/admin/UserProfile.tsx b/public/app/features/admin/UserProfile.tsx index 11e5d5e6e63..13ac137ae42 100644 --- a/public/app/features/admin/UserProfile.tsx +++ b/public/app/features/admin/UserProfile.tsx @@ -220,7 +220,9 @@ export class UserProfileRow extends PureComponent, status?: LegacyInputStatus) => { @@ -228,7 +230,9 @@ export class UserProfileRow extends PureComponent { diff --git a/public/app/features/admin/utils.ts b/public/app/features/admin/utils.ts index f4bdf531730..246f1279433 100644 --- a/public/app/features/admin/utils.ts +++ b/public/app/features/admin/utils.ts @@ -1,5 +1,9 @@ import { config } from '@grafana/runtime/src'; +// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address +export const w3cStandardEmailValidator = + /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + export function isTrial() { const expiry = config.licenseInfo?.trialExpiry; return !!(expiry && expiry > 0); diff --git a/public/app/features/invites/SignupInvited.tsx b/public/app/features/invites/SignupInvited.tsx index 446d15a217a..7435932e347 100644 --- a/public/app/features/invites/SignupInvited.tsx +++ b/public/app/features/invites/SignupInvited.tsx @@ -8,6 +8,8 @@ import { getConfig } from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { w3cStandardEmailValidator } from '../admin/utils'; + interface FormModel { email: string; name?: string; @@ -77,7 +79,7 @@ export const SignupInvitedPage: FC = ({ match }) => { {...register('email', { required: 'Email is required', pattern: { - value: /^\S+@\S+$/, + value: w3cStandardEmailValidator, message: 'Email is invalid', }, })} From a9458c8c00c5aba6efcbf9879662ebdd6174c248 Mon Sep 17 00:00:00 2001 From: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Date: Mon, 14 Nov 2022 09:24:39 -0600 Subject: [PATCH 225/926] Docs: corrects relrefs (#58706) corrects relrefs --- .../setup-grafana/image-rendering/troubleshooting/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md index 492bd4eb3c7..02b1e310da0 100644 --- a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md +++ b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md @@ -30,9 +30,9 @@ filters = rendering:debug You can also enable more logs in image renderer service itself by: -- Increasing the [log level]({{< relref "image-rendering#log-level" >}}). -- Enabling [verbose logging]({{< relref "image-rendering#verbose-logging" >}}). -- [Capturing headless browser output]({{< relref "image-rendering#capture-browser-output" >}}). +- Increasing the [log level]({{< relref "../../image-rendering#log-level" >}}). +- Enabling [verbose logging]({{< relref "../../image-rendering#verbose-logging" >}}). +- [Capturing headless browser output]({{< relref "../../image-rendering#capture-browser-output" >}}). ## Missing libraries From 4915d21c25891dae2ee7a4eec4fe0dd2659d614d Mon Sep 17 00:00:00 2001 From: Misi Date: Mon, 14 Nov 2022 16:47:46 +0100 Subject: [PATCH 226/926] OAuth: Feature toggle for access token expiration check and docs (#58179) * Add feature toggle for access token expiration check * Add docs for configuring refresh tokens * Update docs * Update docs based on review Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Improve documentation * Change access_type default to Offline * Update docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update pkg/services/featuremgmt/registry.go Co-authored-by: Eric Leijonmarck * Regenerate toggles * Update Generic OAuth docs Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Eric Leijonmarck --- .../configure-authentication/azuread/index.md | 12 +++++ .../generic-oauth/index.md | 23 +++++++++- .../configure-authentication/github/index.md | 8 ++++ .../configure-authentication/gitlab/index.md | 12 +++++ .../configure-authentication/google/index.md | 12 +++++ .../keycloak/index.md | 12 +++++ .../configure-authentication/okta/index.md | 13 ++++++ .../src/types/featureToggles.gen.ts | 1 + pkg/api/common_test.go | 2 +- pkg/api/login_oauth.go | 3 +- pkg/middleware/middleware_test.go | 3 +- .../contexthandler/auth_proxy_test.go | 2 +- pkg/services/contexthandler/contexthandler.go | 45 ++++++++++--------- pkg/services/featuremgmt/registry.go | 5 +++ pkg/services/featuremgmt/toggles_gen.go | 4 ++ 15 files changed, 131 insertions(+), 26 deletions(-) diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index 3932b82a7e0..42d1760231a 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -169,6 +169,18 @@ GF_AUTH_AZUREAD_CLIENT_SECRET **Note:** Verify that the Grafana [root_url]({{< relref "../../../configure-grafana/#root-url" >}}) is set in your Azure Application Redirect URLs. +### Configure refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token. + +Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired. + +To enable a refresh token for AzureAD, extend the `scopes` in `[auth.azuread]` with `offline_access`. + ### Configure allowed groups To limit access to authenticated users who are members of one or more groups, set `allowed_groups` diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md index 90fce67d538..879b15cb51c 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth/index.md @@ -116,9 +116,24 @@ use_pkce = true Grafana always uses the SHA256 based `S256` challenge method and a 128 bytes (base64url encoded) code verifier. +### Configure refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token. + +Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired. + +To configure Generic OAuth to use a refresh token, perform one or both of the following tasks, if required: + +- Extend the `[auth.generic_oauth]` section with additional scopes +- Enable the refresh token on the provider + ## Set up OAuth2 with Auth0 -1. Create a new Client in Auth0 +1. Use the following parameters to create a client in Auth0: - Name: Grafana - Type: Regular Web Application @@ -138,7 +153,7 @@ Grafana always uses the SHA256 based `S256` challenge method and a 128 bytes (ba name = Auth0 client_id = client_secret = - scopes = openid profile email + scopes = openid profile email offline_access auth_url = https:///authorize token_url = https:///oauth/token api_url = https:///userinfo @@ -164,6 +179,8 @@ team_ids = allowed_organizations = ``` +By default, a refresh token is included in the response for the **Authorization Code Grant**. + ## Set up OAuth2 with Centrify 1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard. @@ -195,6 +212,8 @@ allowed_organizations = api_url = https://.my.centrify.com/OAuth2/UserInfo/ ``` +By default, a refresh token is included in the response for the **Authorization Code Grant**. + ## Set up OAuth2 with OneLogin 1. Create a new Custom Connector with the following settings: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md index 1f8c90fb14e..dad8809d78e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github/index.md @@ -64,6 +64,14 @@ automatically signed up. You can also use [variable expansion]({{< relref "../../../configure-grafana/#variable-expansion" >}}) to reference environment variables and local files in your GitHub auth configuration. +### GitHub refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +GitHub OAuth applications do not support refresh tokens because the provided access tokens do not expire. + ### team_ids Require an active team membership for at least one of the given teams on diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md index 78a4049b498..f738c640e7a 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab/index.md @@ -81,6 +81,18 @@ to login on your Grafana instance. You can limit access to only members of a given group or list of groups by setting the `allowed_groups` option. +### Configure refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token. + +Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired. + +By default, GitLab provides a refresh token. + ### allowed_groups To limit access to authenticated users that are members of one or more [GitLab diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md index 5af9b6fda29..91ef70ae5e1 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/google/index.md @@ -53,3 +53,15 @@ You may allow users to sign-up via Google authentication by setting the `allow_sign_up` option to `true`. When this option is set to `true`, any user successfully authenticating via Google authentication will be automatically signed up. + +### Configure refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token. + +Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired. + +By default, Grafana includes the `access_type=offline` parameter in the authorization request to request a refresh token. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md index dac2111f587..c5129835cc1 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md @@ -141,3 +141,15 @@ Grafana also assigns the user the `Admin` role of the default organization. role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' allow_assign_grafana_admin = true ``` + +### Configure refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token. + +Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired. + +To enable a refresh token for Keycloak, extend the `scopes` in `[auth.generic_oauth]` with `offline_access`. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md index c4eac856b34..86c2858aae3 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta/index.md @@ -54,6 +54,19 @@ allowed_groups = role_attribute_path = ``` +### Configure refresh token + +> Available in Grafana v9.3 and later versions. + +> **Note:** This feature is behind the `accessTokenExpirationCheck` feature toggle. + +When a user logs in using an OAuth provider, Grafana verifies that the access token has not expired. When an access token expires, Grafana uses the provided refresh token (if any exists) to obtain a new access token. + +Grafana uses a refresh token to obtain a new access token without requiring the user to log in again. If a refresh token doesn't exist, Grafana logs the user out of the system after the access token has expired. + +1. To enable the `Refresh Token`, grant type in the `General Settings` section. +1. Extend the `scopes` in `[auth.okta]` with `offline_access`. + ### Configure allowed groups and domains To limit access to authenticated users that are members of one or more groups, set `allowed_groups` diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c115b43a80a..c4b9823408c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -80,5 +80,6 @@ export interface FeatureToggles { datasourceLogger?: boolean; accessControlOnCall?: boolean; nestedFolders?: boolean; + accessTokenExpirationCheck?: boolean; elasticsearchBackendMigration?: boolean; } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 8ca9c240b8e..4f91f5621e6 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -215,7 +215,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginservice.LoginServiceMock{}, &usertest.FakeUserService{}, sqlStore) loginService := &logintest.LoginServiceFake{} authenticator := &logintest.AuthenticatorFake{} - ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil) + ctxHdlr := contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, usertest.NewUserServiceFake(), orgtest.NewOrgServiceFake(), nil, featuremgmt.WithFeatures()) return ctxHdlr } diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 68566f702d8..88a465d7fdb 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -97,7 +97,8 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { code := ctx.Query("code") if code == "" { - opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOnline} + // FIXME: access_type is a Google OAuth2 specific thing, consider refactoring this and moving to google_oauth.go + opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} if provider.UsePKCE { ascii, pkce, err := genPKCECode() diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 21dc23540f9..28839e6198c 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/navtree" @@ -832,7 +833,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.Fake tracer := tracing.InitializeTracerForTest() authProxy := authproxy.ProvideAuthProxy(cfg, remoteCacheSvc, loginService, userService, mockSQLStore) authenticator := &logintest.AuthenticatorFake{ExpectedUser: &user.User{}} - return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService) + return contexthandler.ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, mockSQLStore, tracer, authProxy, loginService, apiKeyService, authenticator, userService, orgService, oauthTokenService, featuremgmt.WithFeatures(featuremgmt.FlagAccessTokenExpirationCheck)) } type fakeRenderService struct { diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index b62e6e5d97c..9e6a629ab2b 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -104,7 +104,7 @@ func getContextHandler(t *testing.T) *ContextHandler { return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc, renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator, - &userService, orgService, nil) + &userService, orgService, nil, nil) } type FakeGetSignUserStore struct { diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index a19c8c2d7fb..0179a8bccf0 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" @@ -46,7 +47,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore db.DB, tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service, - orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, + orgService org.Service, oauthTokenService oauthtoken.OAuthTokenService, features *featuremgmt.FeatureManager, ) *ContextHandler { return &ContextHandler{ Cfg: cfg, @@ -63,6 +64,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS userService: userService, orgService: orgService, oauthTokenService: oauthTokenService, + features: features, } } @@ -82,6 +84,7 @@ type ContextHandler struct { userService user.Service orgService org.Service oauthTokenService oauthtoken.OAuthTokenService + features *featuremgmt.FeatureManager // GetTime returns the current time. // Stubbable by tests. GetTime func() time.Time @@ -445,29 +448,31 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org getTime = time.Now } - // Check whether the logged in User has a token (whether the User used an OAuth provider to login) - oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult) - if exists { - // Skip where the OAuthExpiry is default/zero/unset - if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) { - reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry)) + if h.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { + // Check whether the logged in User has a token (whether the User used an OAuth provider to login) + oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult) + if exists { + // Skip where the OAuthExpiry is default/zero/unset + if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) { + reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry)) - // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens - if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil { - if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) { - reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err) - } + // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens + if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil { + if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) { + reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err) + } - reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) - if err = h.oauthTokenService.InvalidateOAuthTokens(ctx, oauthToken); err != nil { - reqContext.Logger.Error("could not invalidate OAuth tokens", "userId", oauthToken.UserId, "error", err) - } + reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) + if err = h.oauthTokenService.InvalidateOAuthTokens(ctx, oauthToken); err != nil { + reqContext.Logger.Error("could not invalidate OAuth tokens", "userId", oauthToken.UserId, "error", err) + } - err = h.AuthTokenService.RevokeToken(ctx, token, false) - if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { - reqContext.Logger.Error("failed to revoke auth token", "error", err) + err = h.AuthTokenService.RevokeToken(ctx, token, false) + if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { + reqContext.Logger.Error("failed to revoke auth token", "error", err) + } + return false } - return false } } } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 39f308edf15..62352cfe6a3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -357,6 +357,11 @@ var ( State: FeatureStateAlpha, RequiresDevMode: true, }, + { + Name: "accessTokenExpirationCheck", + Description: "Enable OAuth access_token expiration check and token refresh using the refresh_token", + State: FeatureStateStable, + }, { Name: "elasticsearchBackendMigration", Description: "Use Elasticsearch as backend data source", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 325fa48559a..2441a1ab0fe 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -263,6 +263,10 @@ const ( // Enable folder nesting FlagNestedFolders = "nestedFolders" + // FlagAccessTokenExpirationCheck + // Enable OAuth access_token expiration check and token refresh using the refresh_token + FlagAccessTokenExpirationCheck = "accessTokenExpirationCheck" + // FlagElasticsearchBackendMigration // Use Elasticsearch as backend data source FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" From 28d39d35fd820ce9353fd5e5fd31820f9dd445f8 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 14 Nov 2022 10:57:51 -0500 Subject: [PATCH 227/926] Alerting: Update state manager to save state transitions in one batch (#58358) * change stale results handler to not update database but return transitions * save all transitions in one call --- pkg/services/ngalert/state/manager.go | 136 +++++++++++++++----------- 1 file changed, 77 insertions(+), 59 deletions(-) diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 912fc05f314..388adb81403 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -176,26 +176,23 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, evaluatedAt time.Time s := st.setNextState(ctx, alertRule, result, extraLabels, logger) states = append(states, s) } - resolvedStates := st.staleResultsHandler(ctx, logger, alertRule, evaluatedAt) + staleStates := st.deleteStaleStatesFromCache(ctx, logger, evaluatedAt, alertRule) + st.deleteAlertStates(ctx, logger, staleStates) st.saveAlertStates(ctx, logger, states...) - changedStates := make([]StateTransition, 0, len(states)) - for _, s := range states { - if s.changed() { - changedStates = append(changedStates, s) - } - } + st.logStateTransitions(ctx, alertRule, states, staleStates) - if st.historian != nil { - st.historian.RecordStates(ctx, alertRule, changedStates) - } - - deltas := append(states, resolvedStates...) nextStates := make([]*State, 0, len(states)) - for _, s := range deltas { + for _, s := range states { nextStates = append(nextStates, s.State) } + // TODO refactor further. Do not filter because it will be filtered downstream + for _, s := range staleStates { + if s.PreviousState == eval.Alerting { + nextStates = append(nextStates, s.State) + } + } return nextStates } @@ -281,7 +278,7 @@ func (st *Manager) Put(states []*State) { // TODO: Is the `State` type necessary? Should it embed the instance? func (st *Manager) saveAlertStates(ctx context.Context, logger log.Logger, states ...StateTransition) { - if st.instanceStore == nil { + if st.instanceStore == nil || len(states) == 0 { return } @@ -319,6 +316,49 @@ func (st *Manager) saveAlertStates(ctx context.Context, logger log.Logger, state } } +func (st *Manager) logStateTransitions(ctx context.Context, alertRule *ngModels.AlertRule, newStates, staleStates []StateTransition) { + if st.historian == nil { + return + } + changedStates := make([]StateTransition, 0, len(staleStates)) + for _, s := range newStates { + if s.changed() { + changedStates = append(changedStates, s) + } + } + + // TODO refactor further. Let historian decide what to log. Current logic removes states `Normal (reason-X) -> Normal (reason-Y)` + for _, t := range staleStates { + if t.PreviousState == eval.Alerting { + changedStates = append(changedStates, t) + } + } + st.historian.RecordStates(ctx, alertRule, changedStates) +} + +func (st *Manager) deleteAlertStates(ctx context.Context, logger log.Logger, states []StateTransition) { + if st.instanceStore == nil || len(states) == 0 { + return + } + + logger.Debug("Deleting alert states", "count", len(states)) + toDelete := make([]ngModels.AlertInstanceKey, 0, len(states)) + + for _, s := range states { + key, err := s.GetAlertInstanceKey() + if err != nil { + logger.Error("Failed to delete alert instance with invalid labels", "cacheID", s.CacheID, "error", err) + continue + } + toDelete = append(toDelete, key) + } + + err := st.instanceStore.DeleteAlertInstances(ctx, toDelete...) + if err != nil { + logger.Error("Failed to delete stale states", "error", err) + } +} + // TODO: why wouldn't you allow other types like NoData or Error? func translateInstanceState(state ngModels.InstanceStateType) eval.State { switch { @@ -331,72 +371,50 @@ func translateInstanceState(state ngModels.InstanceStateType) eval.State { } } -func (st *Manager) staleResultsHandler(ctx context.Context, logger log.Logger, r *ngModels.AlertRule, evaluatedAt time.Time) []StateTransition { - var ( - // resolvedImage contains the image for all stale states that are resolved. The resolved image is shared between - // all resolved states as the alert rule is the same. TODO: We will need to change this when we support images - // without screenshots as each state will have a different image - resolvedImage *ngModels.Image +func (st *Manager) deleteStaleStatesFromCache(ctx context.Context, logger log.Logger, evaluatedAt time.Time, alertRule *ngModels.AlertRule) []StateTransition { + // If we are removing two or more stale series it makes sense to share the resolved image as the alert rule is the same. + // TODO: We will need to change this when we support images without screenshots as each series will have a different image + var resolvedImage *ngModels.Image - // resolvedStates contains the stale states that were resolved - resolvedStates []StateTransition - - // staleStates contains the current set of stale states from the state cache - staleStates []*State - - // toDelete contains the stale states to delete - toDelete []ngModels.AlertInstanceKey - ) - - staleStates = st.cache.deleteRuleStates(r.GetKey(), func(s *State) bool { - return stateIsStale(evaluatedAt, s.LastEvaluationTime, r.IntervalSeconds) + var resolvedStates []StateTransition + staleStates := st.cache.deleteRuleStates(alertRule.GetKey(), func(s *State) bool { + return stateIsStale(evaluatedAt, s.LastEvaluationTime, alertRule.IntervalSeconds) }) for _, s := range staleStates { logger.Info("Detected stale state entry", "cacheID", s.CacheID, "state", s.State, "reason", s.StateReason) + oldState := s.State + oldReason := s.StateReason - key, err := s.GetAlertInstanceKey() - if err != nil { - logger.Error("Unable to get alert instance key to delete it from database. Ignoring", "error", err) - } else { - toDelete = append(toDelete, key) - } - - // If the stale state is alerting then it should first be resolved - if s.State == eval.Alerting { - t := StateTransition{PreviousState: s.State, PreviousStateReason: s.StateReason} - s.Resolve(ngModels.StateReasonMissingSeries, evaluatedAt) - s.LastEvaluationTime = evaluatedAt + s.State = eval.Normal + s.StateReason = ngModels.StateReasonMissingSeries + s.EndsAt = evaluatedAt + s.LastEvaluationTime = evaluatedAt + if oldState == eval.Alerting { + s.Resolved = true // If there is no resolved image for this rule then take one if resolvedImage == nil { - image, err := takeImage(ctx, st.images, r) + image, err := takeImage(ctx, st.images, alertRule) if err != nil { logger.Warn("Failed to take an image", - "dashboard", r.GetDashboardUID(), - "panel", r.GetPanelID(), + "dashboard", alertRule.GetDashboardUID(), + "panel", alertRule.GetPanelID(), "error", err) } else if image != nil { resolvedImage = image } } s.Image = resolvedImage - - t.State = s - resolvedStates = append(resolvedStates, t) } - } - if st.historian != nil { - st.historian.RecordStates(ctx, r, resolvedStates) - } - - if st.instanceStore != nil { - if err := st.instanceStore.DeleteAlertInstances(ctx, toDelete...); err != nil { - logger.Error("Unable to delete stale instances from database", "error", err, "count", len(toDelete)) + record := StateTransition{ + State: s, + PreviousState: oldState, + PreviousStateReason: oldReason, } + resolvedStates = append(resolvedStates, record) } - return resolvedStates } From 67bd0d553707da8e969d5cc4a3743688240969ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Mon, 14 Nov 2022 17:16:34 +0100 Subject: [PATCH 228/926] Internationalization: Translate TimeRangePicker component (#58470) --- .../DateTimePickers/TimeRangePicker.test.tsx | 2 +- .../DateTimePickers/TimeRangePicker.tsx | 26 ++++++++++++++----- packages/grafana-ui/src/utils/i18n.tsx | 4 +-- public/locales/de-DE/grafana.json | 8 ++++++ public/locales/en-US/grafana.json | 8 ++++++ public/locales/es-ES/grafana.json | 8 ++++++ public/locales/fr-FR/grafana.json | 8 ++++++ public/locales/pseudo-LOCALE/grafana.json | 8 ++++++ public/locales/zh-Hans/grafana.json | 8 ++++++ 9 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx index 8403577a7d6..fcbdb834914 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx @@ -27,6 +27,6 @@ describe('TimePicker', () => { /> ); - expect(container.queryByLabelText(/Time range picker/i)).toBeInTheDocument(); + expect(container.queryByLabelText(/Time range selected/i)).toBeInTheDocument(); }); }); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index ac9c8a4c332..131ac5db6a7 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -17,6 +17,7 @@ import { import { selectors } from '@grafana/e2e-selectors'; import { useStyles2 } from '../../themes/ThemeContext'; +import { t, Trans } from '../../utils/i18n'; import { ButtonGroup } from '../Button'; import { ToolbarButton } from '../ToolbarButton'; import { Tooltip } from '../Tooltip/Tooltip'; @@ -91,11 +92,13 @@ export function TimeRangePicker(props: TimeRangePickerProps) { const hasAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to); const variant = isSynced ? 'active' : isOnCanvas ? 'canvas' : 'default'; + const currentTimeRange = formattedRange(value, timeZone); + return ( {hasAbsolute && ( } placement="bottom" interactive> - + ); @@ -159,7 +169,9 @@ TimeRangePicker.displayName = 'TimeRangePicker'; const ZoomOutTooltip = () => ( <> - Time range zoom out
CTRL+Z + + Time range zoom out
CTRL+Z +
); @@ -169,7 +181,9 @@ const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; time return ( <> {dateTimeFormat(timeRange.from, { timeZone })} -
to
+
+ to +
{dateTimeFormat(timeRange.to, { timeZone })}
{timeZoneFormatUserFriendly(timeZone)} diff --git a/packages/grafana-ui/src/utils/i18n.tsx b/packages/grafana-ui/src/utils/i18n.tsx index 588523e79d4..a721e5066ee 100644 --- a/packages/grafana-ui/src/utils/i18n.tsx +++ b/packages/grafana-ui/src/utils/i18n.tsx @@ -28,7 +28,7 @@ export const Trans: typeof I18NextTrans = (props) => { return ; }; -export const t = (id: string, defaultMessage: string) => { +export const t = (id: string, defaultMessage: string, values?: Record) => { initI18n(); - return i18next.t(id, defaultMessage); + return i18next.t(id, defaultMessage, values); }; diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 27cf2529b5c..81e5b3138ee 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -436,6 +436,14 @@ "range-error": "", "to-input": "" }, + "range-picker": { + "backwards-time-aria-label": "", + "current-time-selected": "", + "forwards-time-aria-label": "", + "to": "", + "zoom-out-button": "", + "zoom-out-tooltip": "" + }, "time-range": { "aria-role": "", "default-title": "", diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f651674b9b8..c75ec38b75f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -436,6 +436,14 @@ "range-error": "\"From\" can't be after \"To\"", "to-input": "To" }, + "range-picker": { + "backwards-time-aria-label": "Move time range backwards", + "current-time-selected": "Time range selected: {{currentTimeRange}}", + "forwards-time-aria-label": "Move time range forwards", + "to": "to", + "zoom-out-button": "Zoom out time range", + "zoom-out-tooltip": "Time range zoom out <1> CTRL+Z" + }, "time-range": { "aria-role": "Time range selection", "default-title": "Time ranges", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index d91371180db..87235fc3043 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -436,6 +436,14 @@ "range-error": "", "to-input": "" }, + "range-picker": { + "backwards-time-aria-label": "", + "current-time-selected": "", + "forwards-time-aria-label": "", + "to": "", + "zoom-out-button": "", + "zoom-out-tooltip": "" + }, "time-range": { "aria-role": "", "default-title": "", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7b27e4bc8d9..dea096a2e69 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -436,6 +436,14 @@ "range-error": "", "to-input": "" }, + "range-picker": { + "backwards-time-aria-label": "", + "current-time-selected": "", + "forwards-time-aria-label": "", + "to": "", + "zoom-out-button": "", + "zoom-out-tooltip": "" + }, "time-range": { "aria-role": "", "default-title": "", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index c35edcd0d73..b4a20777dec 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -436,6 +436,14 @@ "range-error": "\"Fřőm\" čäʼn'ŧ þę äƒŧęř \"Ŧő\"", "to-input": "Ŧő" }, + "range-picker": { + "backwards-time-aria-label": "Mővę ŧįmę řäʼnģę þäčĸŵäřđş", + "current-time-selected": "Ŧįmę řäʼnģę şęľęčŧęđ: {{čūřřęʼnŧŦįmęŖäʼnģę}}", + "forwards-time-aria-label": "Mővę ŧįmę řäʼnģę ƒőřŵäřđş", + "to": "ŧő", + "zoom-out-button": "Żőőm őūŧ ŧįmę řäʼnģę", + "zoom-out-tooltip": "Ŧįmę řäʼnģę žőőm őūŧ <1> CŦŖĿ+Ż" + }, "time-range": { "aria-role": "Ŧįmę řäʼnģę şęľęčŧįőʼn", "default-title": "Ŧįmę řäʼnģęş", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index aef95b3bb87..0d11d8a32b2 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -436,6 +436,14 @@ "range-error": "", "to-input": "" }, + "range-picker": { + "backwards-time-aria-label": "", + "current-time-selected": "", + "forwards-time-aria-label": "", + "to": "", + "zoom-out-button": "", + "zoom-out-tooltip": "" + }, "time-range": { "aria-role": "", "default-title": "", From dd0d034796c1c0a3b0ed5aa2924e602bb2d77a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Mon, 14 Nov 2022 17:47:15 +0100 Subject: [PATCH 229/926] Internationalization: Translate RefreshPicker component (#58530) --- .../RefreshPicker/RefreshPicker.tsx | 71 +++++++++---------- .../DashNav/DashNavTimeControls.tsx | 12 ---- public/locales/de-DE/grafana.json | 23 ++++-- public/locales/en-US/grafana.json | 23 ++++-- public/locales/es-ES/grafana.json | 23 ++++-- public/locales/fr-FR/grafana.json | 23 ++++-- public/locales/pseudo-LOCALE/grafana.json | 23 ++++-- public/locales/zh-Hans/grafana.json | 23 ++++-- 8 files changed, 137 insertions(+), 84 deletions(-) diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index a30cf16c6c3..e2555222657 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -4,6 +4,7 @@ import React, { PureComponent } from 'react'; import { SelectableValue, parseDuration } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { t } from '../../utils/i18n'; import { ButtonGroup } from '../Button'; import { ButtonSelect } from '../Dropdown/ButtonSelect'; import { ToolbarButtonVariant, ToolbarButton } from '../ToolbarButton'; @@ -24,11 +25,6 @@ export interface Props { width?: string; primary?: boolean; isOnCanvas?: boolean; - // These props are used to translate the component - offOptionLabelMsg?: string; - offOptionAriaLabelMsg?: string; - offDescriptionAriaLabelMsg?: string; - onDescriptionAriaLabelMsg?: (durationAriaLabel: string | undefined) => string; } export class RefreshPicker extends PureComponent { @@ -42,6 +38,7 @@ export class RefreshPicker extends PureComponent { value: 'LIVE', ariaLabel: 'Turn on live streaming', }; + static isLive = (refreshInterval?: string): boolean => refreshInterval === RefreshPicker.liveOption.value; constructor(props: Props) { @@ -72,30 +69,13 @@ export class RefreshPicker extends PureComponent { } render() { - const { - onRefresh, - intervals, - tooltip, - value, - text, - isLoading, - noIntervalPicker, - width, - offOptionLabelMsg, - offOptionAriaLabelMsg, - offDescriptionAriaLabelMsg, - onDescriptionAriaLabelMsg, - } = this.props; + const { onRefresh, intervals, tooltip, value, text, isLoading, noIntervalPicker, width } = this.props; const currentValue = value || ''; const variant = this.getVariant(); - const translatedOffOption = { - value: RefreshPicker.offOption.value, - label: offOptionLabelMsg || RefreshPicker.offOption.label, - ariaLabel: offOptionAriaLabelMsg || RefreshPicker.offOption.ariaLabel, - }; - const options = intervalsToOptions({ intervals, offOption: translatedOffOption }); + const options = intervalsToOptions({ intervals }); const option = options.find(({ value }) => value === currentValue); + const translatedOffOption = translateOption(RefreshPicker.offOption.value); let selectedValue = option || translatedOffOption; if (selectedValue.label === translatedOffOption.label) { @@ -103,11 +83,16 @@ export class RefreshPicker extends PureComponent { } const durationAriaLabel = selectedValue.ariaLabel; - const ariaLabel = - selectedValue.value === '' - ? offDescriptionAriaLabelMsg || 'Auto refresh turned off. Choose refresh time interval' - : onDescriptionAriaLabelMsg?.(durationAriaLabel) || - `Choose refresh time interval with current interval ${durationAriaLabel} selected`; + const ariaLabelDurationSelectedMessage = t( + 'refresh-picker.aria-label.duration-selected', + 'Choose refresh time interval with current interval {{durationAriaLabel}} selected', + { durationAriaLabel } + ); + const ariaLabelChooseIntervalMessage = t( + 'refresh-picker.aria-label.choose-interval', + 'Auto refresh turned off. Choose refresh time interval' + ); + const ariaLabel = selectedValue.value === '' ? ariaLabelChooseIntervalMessage : ariaLabelDurationSelectedMessage; return ( @@ -128,7 +113,7 @@ export class RefreshPicker extends PureComponent { options={options} onChange={this.onChangeSelect} variant={variant} - title="Set auto refresh interval" + title={t('refresh-picker.select-button.auto-refresh', 'Set auto refresh interval')} data-testid={selectors.components.RefreshPicker.intervalButtonV2} aria-label={ariaLabel} /> @@ -138,10 +123,24 @@ export class RefreshPicker extends PureComponent { } } -export function intervalsToOptions({ - intervals = defaultIntervals, - offOption = RefreshPicker.offOption, -}: { intervals?: string[]; offOption?: SelectableValue } = {}): Array> { +export function translateOption(option: string) { + if (option === RefreshPicker.liveOption.value) { + return { + label: t('refresh-picker.live-option.label', 'Live'), + value: 'LIVE', + ariaLabel: t('refresh-picker.live-option.aria-label', 'Turn on live streaming'), + }; + } + return { + label: t('refresh-picker.off-option.label', 'Off'), + value: '', + ariaLabel: t('refresh-picker.off-option.aria-label', 'Turn off auto refresh'), + }; +} + +export function intervalsToOptions({ intervals = defaultIntervals }: { intervals?: string[] } = {}): Array< + SelectableValue +> { const options: Array> = intervals.map((interval) => { const duration = parseDuration(interval); const ariaLabel = formatDuration(duration); @@ -153,6 +152,6 @@ export function intervalsToOptions({ }; }); - options.unshift(offOption); + options.unshift(translateOption(RefreshPicker.offOption.value)); return options; } diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 29edceb7224..66743294ca7 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -109,18 +109,6 @@ export class DashNavTimeControls extends Component { isOnCanvas={isOnCanvas} tooltip={t('dashboard.toolbar.refresh', 'Refresh dashboard')} noIntervalPicker={hideIntervalPicker} - offDescriptionAriaLabelMsg={t( - 'dashboard.refresh-picker.off-description', - 'Auto refresh turned off. Choose refresh time interval' - )} - onDescriptionAriaLabelMsg={(durationAriaLabel) => - t( - 'dashboard.refresh-picker.on-description', - `Choose refresh time interval with current interval ${durationAriaLabel} selected` - ) - } - offOptionLabelMsg={t('dashboard.refresh-picker.off-label', 'Off')} - offOptionAriaLabelMsg={t('dashboard.refresh-picker.off-arialabel', 'Turn off auto refresh')} /> ); diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 81e5b3138ee..cada8dd06d3 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -59,12 +59,6 @@ "rows": "Gesamtanzahl an Zeilen", "table-title": "Statistiken" }, - "refresh-picker": { - "off-arialabel": "Automatische Aktualisierung deaktivieren", - "off-description": "Automatische Aktualisierung deaktiviert. Zeitintervall für Aktualisierungen auswählen", - "off-label": "Aus", - "on-description": "" - }, "toolbar": { "add-panel": "Panel hinzufügen", "comments-show": "Dashboard-Kommentare anzeigen", @@ -315,6 +309,23 @@ "view": "Anzeigen" } }, + "refresh-picker": { + "aria-label": { + "choose-interval": "", + "duration-selected": "" + }, + "live-option": { + "aria-label": "", + "label": "" + }, + "off-option": { + "aria-label": "", + "label": "" + }, + "select-button": { + "auto-refresh": "" + } + }, "share-modal": { "dashboard": { "title": "Teilen" diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c75ec38b75f..afd9de68fa6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -59,12 +59,6 @@ "rows": "Total number rows", "table-title": "Stats" }, - "refresh-picker": { - "off-arialabel": "Turn off auto refresh", - "off-description": "Auto refresh turned off. Choose refresh time interval", - "off-label": "Off", - "on-description": "" - }, "toolbar": { "add-panel": "Add panel", "comments-show": "Show dashboard comments", @@ -315,6 +309,23 @@ "view": "View" } }, + "refresh-picker": { + "aria-label": { + "choose-interval": "Auto refresh turned off. Choose refresh time interval", + "duration-selected": "Choose refresh time interval with current interval {{durationAriaLabel}} selected" + }, + "live-option": { + "aria-label": "Turn on live streaming", + "label": "Live" + }, + "off-option": { + "aria-label": "Turn off auto refresh", + "label": "Off" + }, + "select-button": { + "auto-refresh": "Set auto refresh interval" + } + }, "share-modal": { "dashboard": { "title": "Share" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 87235fc3043..d757a814bc4 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -59,12 +59,6 @@ "rows": "Número total de filas", "table-title": "Estadísticas" }, - "refresh-picker": { - "off-arialabel": "Desactivar actualización automática", - "off-description": "Actualización automática desactivada. Elija un intervalo de tiempo de actualización", - "off-label": "Apagado", - "on-description": "" - }, "toolbar": { "add-panel": "Añadir panel", "comments-show": "Mostrar comentarios del panel de control", @@ -315,6 +309,23 @@ "view": "Vista" } }, + "refresh-picker": { + "aria-label": { + "choose-interval": "", + "duration-selected": "" + }, + "live-option": { + "aria-label": "", + "label": "" + }, + "off-option": { + "aria-label": "", + "label": "" + }, + "select-button": { + "auto-refresh": "" + } + }, "share-modal": { "dashboard": { "title": "Compartir" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index dea096a2e69..797aaeb31c2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -59,12 +59,6 @@ "rows": "Nombre total de lignes", "table-title": "Statistiques" }, - "refresh-picker": { - "off-arialabel": "Désactiver l'actualisation automatique", - "off-description": "Actualisation automatique désactivée. Choisir un intervalle de temps d'actualisation", - "off-label": "Désactivé", - "on-description": "" - }, "toolbar": { "add-panel": "Ajouter un panneau", "comments-show": "Afficher les commentaires du tableau de bord", @@ -315,6 +309,23 @@ "view": "Afficher" } }, + "refresh-picker": { + "aria-label": { + "choose-interval": "", + "duration-selected": "" + }, + "live-option": { + "aria-label": "", + "label": "" + }, + "off-option": { + "aria-label": "", + "label": "" + }, + "select-button": { + "auto-refresh": "" + } + }, "share-modal": { "dashboard": { "title": "Partager" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index b4a20777dec..de4478874a4 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -59,12 +59,6 @@ "rows": "Ŧőŧäľ ʼnūmþęř řőŵş", "table-title": "Ŝŧäŧş" }, - "refresh-picker": { - "off-arialabel": "Ŧūřʼn őƒƒ äūŧő řęƒřęşĥ", - "off-description": "Åūŧő řęƒřęşĥ ŧūřʼnęđ őƒƒ. Cĥőőşę řęƒřęşĥ ŧįmę įʼnŧęřväľ", - "off-label": "؃ƒ", - "on-description": "" - }, "toolbar": { "add-panel": "Åđđ päʼnęľ", "comments-show": "Ŝĥőŵ đäşĥþőäřđ čőmmęʼnŧş", @@ -315,6 +309,23 @@ "view": "Vįęŵ" } }, + "refresh-picker": { + "aria-label": { + "choose-interval": "Åūŧő řęƒřęşĥ ŧūřʼnęđ őƒƒ. Cĥőőşę řęƒřęşĥ ŧįmę įʼnŧęřväľ", + "duration-selected": "Cĥőőşę řęƒřęşĥ ŧįmę įʼnŧęřväľ ŵįŧĥ čūřřęʼnŧ įʼnŧęřväľ {{đūřäŧįőʼnÅřįäĿäþęľ}} şęľęčŧęđ" + }, + "live-option": { + "aria-label": "Ŧūřʼn őʼn ľįvę şŧřęämįʼnģ", + "label": "Ŀįvę" + }, + "off-option": { + "aria-label": "Ŧūřʼn őƒƒ äūŧő řęƒřęşĥ", + "label": "؃ƒ" + }, + "select-button": { + "auto-refresh": "Ŝęŧ äūŧő řęƒřęşĥ įʼnŧęřväľ" + } + }, "share-modal": { "dashboard": { "title": "Ŝĥäřę" diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 0d11d8a32b2..cc4d4fd3512 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -59,12 +59,6 @@ "rows": "总行数", "table-title": "统计信息" }, - "refresh-picker": { - "off-arialabel": "关闭自动刷新", - "off-description": "自动刷新已关闭。选择刷新时间间隔", - "off-label": "关", - "on-description": "" - }, "toolbar": { "add-panel": "添加面板", "comments-show": "显示仪表板备注", @@ -315,6 +309,23 @@ "view": "查看" } }, + "refresh-picker": { + "aria-label": { + "choose-interval": "", + "duration-selected": "" + }, + "live-option": { + "aria-label": "", + "label": "" + }, + "off-option": { + "aria-label": "", + "label": "" + }, + "select-button": { + "auto-refresh": "" + } + }, "share-modal": { "dashboard": { "title": "分享" From 9855e74b92f0ed42b9a7a2a264e51fdf07ae1651 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Mon, 14 Nov 2022 21:08:10 +0200 Subject: [PATCH 230/926] Chore: Refactor quota service (#58643) Chore: Refactor quota service (#57586) * Chore: refactore quota service * Apply suggestions from code review --- pkg/api/admin_users.go | 2 +- pkg/api/api.go | 22 +- pkg/api/common_test.go | 18 +- pkg/api/dashboard.go | 2 +- pkg/api/dashboard_test.go | 26 +- pkg/api/folder_test.go | 2 +- pkg/api/metrics_test.go | 8 +- pkg/api/org_test.go | 15 +- pkg/api/org_users_test.go | 60 ++- pkg/api/plugin_dashboards_test.go | 2 +- pkg/api/pluginproxy/ds_proxy_test.go | 118 +++-- pkg/api/plugins_test.go | 2 +- pkg/api/quota.go | 88 ++-- pkg/api/quota_test.go | 31 +- pkg/api/user_test.go | 4 +- pkg/cmd/grafana-cli/runner/wire.go | 2 +- pkg/middleware/quota.go | 6 +- pkg/middleware/quota_test.go | 71 +-- pkg/models/quotas.go | 91 ---- pkg/models/user_token.go | 4 - pkg/server/wire.go | 2 +- .../resourcepermissions/service_test.go | 4 +- .../annotationsimpl/xorm_store_test.go | 9 +- pkg/services/apikey/apikeyimpl/apikey.go | 54 +- pkg/services/apikey/apikeyimpl/sqlx_store.go | 33 ++ pkg/services/apikey/apikeyimpl/store.go | 3 + pkg/services/apikey/apikeyimpl/xorm_store.go | 46 ++ pkg/services/apikey/model.go | 6 + pkg/services/auth/auth_token.go | 51 +- pkg/services/auth/auth_token_test.go | 13 +- pkg/services/auth/model.go | 6 + pkg/services/dashboardimport/api/api.go | 11 +- pkg/services/dashboardimport/api/api_test.go | 5 +- pkg/services/dashboards/dashboard.go | 2 + pkg/services/dashboards/database/acl_test.go | 6 +- pkg/services/dashboards/database/database.go | 86 +++- .../database/database_folder_test.go | 26 +- .../database/database_provisioning_test.go | 5 +- .../dashboards/database/database_test.go | 20 +- pkg/services/dashboards/models.go | 6 + .../dashboard_service_integration_test.go | 75 +-- pkg/services/dashboards/store_mock.go | 7 +- pkg/services/datasources/models.go | 6 + .../datasources/service/datasource.go | 43 +- .../datasources/service/datasource_test.go | 45 +- pkg/services/datasources/service/store.go | 48 ++ .../folder/folderimpl/sqlstore_test.go | 4 +- .../guardian/accesscontrol_guardian_test.go | 8 +- .../libraryelements/libraryelements_test.go | 15 +- .../librarypanels/librarypanels_test.go | 15 +- .../login/loginservice/loginservice.go | 18 +- .../login/loginservice/loginservice_test.go | 8 +- pkg/services/ngalert/api/api.go | 25 + pkg/services/ngalert/api/api_ruler.go | 2 +- pkg/services/ngalert/api/persist.go | 2 + pkg/services/ngalert/models/alert_rule.go | 6 + pkg/services/ngalert/ngalert.go | 42 ++ pkg/services/ngalert/provisioning/persist.go | 2 +- .../provisioning/quota_checker_mock.go | 29 +- pkg/services/ngalert/store/alert_rule.go | 24 + pkg/services/ngalert/tests/fakes/rules.go | 4 + pkg/services/ngalert/tests/util.go | 7 +- pkg/services/org/model.go | 6 + pkg/services/org/orgimpl/org.go | 51 +- pkg/services/org/orgimpl/org_test.go | 5 + pkg/services/org/orgimpl/store.go | 70 +++ .../publicdashboards/api/query_test.go | 4 +- .../database/database_test.go | 48 +- .../publicdashboards/service/query_test.go | 13 +- .../publicdashboards/service/service_test.go | 30 +- pkg/services/query/query_test.go | 5 +- pkg/services/quota/context.go | 42 ++ pkg/services/quota/model.go | 210 +++++++- pkg/services/quota/quota.go | 23 +- pkg/services/quota/quotaimpl/quota.go | 450 +++++++++++------ pkg/services/quota/quotaimpl/quota_test.go | 469 +++++++++++++++++- pkg/services/quota/quotaimpl/store.go | 115 ++++- pkg/services/quota/quotaimpl/store_test.go | 4 +- pkg/services/quota/quotatest/fake.go | 38 +- .../kvstore/migrations/datasource_mig_test.go | 6 +- pkg/services/serviceaccounts/api/api_test.go | 26 +- .../serviceaccounts/api/token_test.go | 9 +- .../serviceaccounts/database/database_test.go | 8 +- pkg/services/serviceaccounts/tests/common.go | 7 +- pkg/services/sqlstore/mockstore/mockstore.go | 28 -- pkg/services/sqlstore/quota.go | 315 ------------ pkg/services/sqlstore/quota_test.go | 301 ----------- pkg/services/sqlstore/store.go | 7 - pkg/services/store/service.go | 68 ++- pkg/services/store/service_test.go | 4 +- pkg/services/user/model.go | 5 + pkg/services/user/userimpl/store.go | 19 + pkg/services/user/userimpl/user.go | 50 +- pkg/services/user/userimpl/user_test.go | 4 + pkg/setting/setting.go | 10 +- pkg/setting/setting_quota.go | 48 +- .../api/alerting/api_alertmanager_test.go | 29 +- pkg/tests/api/alerting/testing.go | 55 ++ pkg/tsdb/legacydata/service/service_test.go | 9 +- 99 files changed, 2596 insertions(+), 1398 deletions(-) delete mode 100644 pkg/models/quotas.go create mode 100644 pkg/services/quota/context.go delete mode 100644 pkg/services/sqlstore/quota.go delete mode 100644 pkg/services/sqlstore/quota_test.go diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index 525e42c9c78..e0b244bfab4 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -250,7 +250,7 @@ func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { return nil }) g.Go(func() error { - if err := hs.QuotaService.DeleteByUser(ctx, cmd.UserID); err != nil { + if err := hs.QuotaService.DeleteQuotaForUser(ctx, cmd.UserID); err != nil { return err } return nil diff --git a/pkg/api/api.go b/pkg/api/api.go index eb2cb600c2f..5eb40707bda 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -36,12 +36,16 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "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/correlations" "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/org" publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -69,8 +73,8 @@ func (hs *HTTPServer) registerRoutes() { // not logged in views r.Get("/logout", hs.Logout) - r.Post("/login", quota("session"), routing.Wrap(hs.LoginPost)) - r.Get("/login/:name", quota("session"), hs.OAuthLogin) + r.Post("/login", quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPost)) + r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin) r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) @@ -173,7 +177,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/verify", hs.Index) r.Get("/signup", hs.Index) r.Get("/api/user/signup/options", routing.Wrap(GetSignUpOptions)) - r.Post("/api/user/signup", quota("user"), routing.Wrap(hs.SignUp)) + r.Post("/api/user/signup", quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.SignUp)) r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2)) // invited @@ -192,7 +196,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index) // api renew session based on cookie - r.Get("/api/login/ping", quota("session"), routing.Wrap(hs.LoginAPIPing)) + r.Get("/api/login/ping", quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginAPIPing)) // expose plugin file system assets r.Get("/public/plugins/:pluginId/*", hs.getPluginAssets) @@ -298,13 +302,13 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Put("/address", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrgAddress)) orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsersWithPaging)) - orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota("user"), routing.Wrap(hs.AddOrgUserToCurrentOrg)) + orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.AddOrgUserToCurrentOrg)) orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUserForCurrentOrg)) orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUserForCurrentOrg)) // invites orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.GetPendingOrgInvites)) - orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota("user"), routing.Wrap(hs.AddOrgInvite)) + orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota(user.QuotaTargetSrv), quota(user.QuotaTargetSrv), routing.Wrap(hs.AddOrgInvite)) orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.RevokeInvite)) // prefs @@ -331,7 +335,7 @@ func (hs *HTTPServer) registerRoutes() { }) // create new org - apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota("org"), routing.Wrap(hs.CreateOrg)) + apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota(org.QuotaTargetSrv), routing.Wrap(hs.CreateOrg)) // search all orgs apiRoute.Get("/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.SearchOrgs)) @@ -358,7 +362,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { apikeyIDScope := ac.Scope("apikeys", "id", ac.Parameter(":id")) keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead)), routing.Wrap(hs.GetAPIKeys)) - keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey)) + keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota(string(apikey.QuotaTargetSrv)), routing.Wrap(hs.AddAPIKey)) keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey)) }) @@ -373,7 +377,7 @@ func (hs *HTTPServer) registerRoutes() { uidScope := datasources.ScopeProvider.GetResourceScopeUID(ac.Parameter(":uid")) nameScope := datasources.ScopeProvider.GetResourceScopeName(ac.Parameter(":name")) datasourceRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionRead)), routing.Wrap(hs.GetDataSources)) - datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota("data_source"), routing.Wrap(hs.AddDataSource)) + datasourceRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionCreate)), quota(string(datasources.QuotaTargetSrv)), routing.Wrap(hs.AddDataSource)) datasourceRoute.Put("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, idScope)), routing.Wrap(hs.UpdateDataSourceByID)) datasourceRoute.Put("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionWrite, uidScope)), routing.Wrap(hs.UpdateDataSourceByUID)) datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(datasources.ActionDelete, idScope)), routing.Wrap(hs.DeleteDataSourceById)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 4f91f5621e6..714f1116412 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -45,7 +45,6 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/preference/preftest" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/search" @@ -249,15 +248,13 @@ func (s *fakeRenderService) Init() error { } func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []accesscontrol.Permission) (*scenarioContext, *HTTPServer) { - cfg.Quota.Enabled = false - - store := db.InitTestDB(t) + store := sqlstore.InitTestDB(t) hs := &HTTPServer{ Cfg: cfg, Live: newTestLive(t, store), License: &licensing.OSSLicensingService{}, Features: featuremgmt.WithFeatures(), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), RouteRegister: routing.NewRouteRegister(), AccessControl: accesscontrolmock.New().WithPermissions(permissions), searchUsersService: searchusers.ProvideUsersService(filters.ProvideOSSSearchUserFilter(), usertest.NewUserServiceFake()), @@ -376,7 +373,9 @@ func setupHTTPServerWithCfgDb( routeRegister := routing.NewRouteRegister() teamService := teamimpl.ProvideService(db, cfg) cfg.IsFeatureToggleEnabled = features.IsEnabled - dashboardsStore := dashboardsstore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, cfg)) + quotaService := quotatest.New(false, nil) + dashboardsStore, err := dashboardsstore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, cfg), quotaService) + require.NoError(t, err) var acmock *accesscontrolmock.Mock var ac accesscontrol.AccessControl @@ -402,7 +401,8 @@ func setupHTTPServerWithCfgDb( acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), featuremgmt.WithFeatures()) require.NoError(t, err) ac = acimpl.ProvideAccessControl(cfg) - userSvc = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService()) + userSvc, err = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) } teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, license, acService, teamService, userSvc) require.NoError(t, err) @@ -412,7 +412,7 @@ func setupHTTPServerWithCfgDb( Cfg: cfg, Features: features, Live: newTestLive(t, db), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotaService, RouteRegister: routeRegister, SQLStore: store, License: &licensing.OSSLicensingService{}, @@ -497,7 +497,7 @@ func SetupAPITestServer(t *testing.T, opts ...APITestServerOption) *webtest.Serv RouteRegister: routing.NewRouteRegister(), License: &licensing.OSSLicensingService{}, Features: featuremgmt.WithFeatures(), - QuotaService: quotatest.NewQuotaServiceFake(), + QuotaService: quotatest.New(false, nil), searchUsersService: &searchusers.OSSService{}, } diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 13e9b05968e..ffb0f0098bc 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -411,7 +411,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa dash := cmd.GetDashboardModel() newDashboard := dash.Id == 0 if newDashboard { - limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") + limitReached, err := hs.QuotaService.QuotaReached(c, dashboards.QuotaTargetSrv) if err != nil { return response.Error(500, "failed to get quota", err) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 3723ccd81a4..42bdd60a957 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -40,7 +40,7 @@ import ( pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/preference/preftest" "github.com/grafana/grafana/pkg/services/provisioning" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -151,6 +151,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, Kinds: corekind.NewBase(nil), + QuotaService: quotatest.New(false, nil), } setUp := func() { @@ -991,9 +992,12 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr provisioningService = provisioning.NewProvisioningServiceMock(context.Background()) } + var err error if dashboardStore == nil { sql := db.InitTestDB(t) - dashboardStore = database.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err = database.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) + require.NoError(t, err) } libraryPanelsService := mockLibraryPanelService{} @@ -1032,7 +1036,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr require.Equal(sc.t, 200, sc.resp.Code) dash := dtos.DashboardFullWithMeta{} - err := json.NewDecoder(sc.resp.Body).Decode(&dash) + err = json.NewDecoder(sc.resp.Body).Decode(&dash) require.NoError(sc.t, err) return dash @@ -1078,12 +1082,10 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { cfg := setting.NewCfg() hs := HTTPServer{ - Cfg: cfg, - ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), - Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{ - Cfg: cfg, - }, + Cfg: cfg, + ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), + Live: newTestLive(t, db.InitTestDB(t)), + QuotaService: quotatest.New(false, nil), pluginStore: &plugins.FakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, @@ -1117,7 +1119,7 @@ func postValidateScenario(t *testing.T, desc string, url string, routePattern st Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, @@ -1153,7 +1155,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, SQLStore: sqlmock, @@ -1191,7 +1193,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout Cfg: cfg, ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()), Live: newTestLive(t, db.InitTestDB(t)), - QuotaService: "aimpl.Service{Cfg: cfg}, + QuotaService: quotatest.New(false, nil), LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, DashboardService: mock, diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 71c4136bae2..5c6ebabf06c 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -145,7 +145,7 @@ func TestHTTPServer_FolderMetadata(t *testing.T) { server := SetupAPITestServer(t, func(hs *HTTPServer) { hs.folderService = folderService hs.AccessControl = acmock.New() - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Should attach access control metadata to multiple folders", func(t *testing.T) { diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index 8f7961a9daf..3992a5cac1d 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -94,12 +94,12 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) { serverFeatureEnabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagDatasourceQueryMultiStatus, true) - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) serverFeatureDisabled := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagDatasourceQueryMultiStatus, false) - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Status code is 400 when data source response has an error and feature toggle is disabled", func(t *testing.T) { @@ -142,7 +142,7 @@ func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) { ) httpServer := SetupAPITestServer(t, func(hs *HTTPServer) { hs.queryDataService = qds - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Status code is 500 and a secrets plugin error is returned if there is a problem getting secrets from the remote plugin", func(t *testing.T) { @@ -294,7 +294,7 @@ func TestDataSourceQueryError(t *testing.T) { pluginClient.ProvideService(r, &config.Cfg{}), &fakeOAuthTokenService{}, ) - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) req := srv.NewPostRequest("/api/ds/query", strings.NewReader(tc.request)) webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleViewer}) diff --git a/pkg/api/org_test.go b/pkg/api/org_test.go index e97d330e312..49ed8000aae 100644 --- a/pkg/api/org_test.go +++ b/pkg/api/org_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" @@ -104,7 +105,8 @@ func TestAPIEndpoint_PutCurrentOrg_LegacyAccessControl(t *testing.T) { }) setInitCtxSignedInOrgAdmin(sc.initCtx) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) t.Run("Admin can update current org", func(t *testing.T) { response := callAPI(sc.server, http.MethodPut, putCurrentOrgURL, input, t) assert.Equal(t, http.StatusOK, response.Code) @@ -118,7 +120,8 @@ func TestAPIEndpoint_PutCurrentOrg_AccessControl(t *testing.T) { _, err := sc.db.CreateOrgWithMember("TestOrg", sc.initCtx.UserID) require.NoError(t, err) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) input := strings.NewReader(testUpdateOrgNameForm) t.Run("AccessControl allows updating current org with correct permissions", func(t *testing.T) { @@ -436,7 +439,9 @@ func TestAPIEndpoint_PutOrg_LegacyAccessControl(t *testing.T) { cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + var err error + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) // Create two orgs, to update another one than the logged in one setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) @@ -456,7 +461,9 @@ func TestAPIEndpoint_PutOrg_LegacyAccessControl(t *testing.T) { func TestAPIEndpoint_PutOrg_AccessControl(t *testing.T) { sc := setupHTTPServer(t, true) - sc.hs.orgService = orgimpl.ProvideService(sc.db, sc.cfg) + var err error + sc.hs.orgService, err = orgimpl.ProvideService(sc.db, sc.cfg, quotatest.New(false, nil)) + require.NoError(t, err) // Create two orgs, to update another one than the logged in one setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 71a6b00db7d..3dbe71300ae 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -22,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -389,11 +390,13 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotatest.New(false, nil)) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) @@ -403,7 +406,7 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { require.Equal(t, tc.expectedCode, response.Code) var userList []*models.OrgUserDTO - err := json.NewDecoder(response.Body).Decode(&userList) + err = json.NewDecoder(response.Body).Decode(&userList) require.NoError(t, err) if tc.expectedMetadata != nil { @@ -493,11 +496,14 @@ func TestGetOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + quotaService := quotatest.New(false, nil) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) + require.NoError(t, err) }) setInitCtxSignedInUser(sc.initCtx, tc.user) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -598,10 +604,11 @@ func TestPostOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -716,11 +723,12 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { + var err error sc := setupHTTPServer(t, true, func(hs *HTTPServer) { hs.tempUserService = tempuserimpl.ProvideService(hs.SQLStore) - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), - ) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, setting.NewCfg(), teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), setting.NewCfg()), localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) }) setInitCtxSignedInViewer(sc.initCtx) setupOrgUsersDBForAccessControlTests(t, sc.db) @@ -835,11 +843,14 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + quotaService := quotatest.New(false, nil) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) @@ -962,11 +973,14 @@ func TestDeleteOrgUsersAPIEndpoint_AccessControl(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = tc.enableAccessControl + var err error sc := setupHTTPServerWithCfg(t, false, cfg, func(hs *HTTPServer) { - hs.userService = userimpl.ProvideService( - hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), - ) - hs.orgService = orgimpl.ProvideService(hs.SQLStore, cfg) + quotaService := quotatest.New(false, nil) + hs.userService, err = userimpl.ProvideService( + hs.SQLStore, nil, cfg, teamimpl.ProvideService(hs.SQLStore.(*sqlstore.SQLStore), cfg), localcache.ProvideService(), quotaService) + require.NoError(t, err) + hs.orgService, err = orgimpl.ProvideService(hs.SQLStore, cfg, quotaService) + require.NoError(t, err) }) setupOrgUsersDBForAccessControlTests(t, sc.db) setInitCtxSignedInUser(sc.initCtx, tc.user) diff --git a/pkg/api/plugin_dashboards_test.go b/pkg/api/plugin_dashboards_test.go index e98116f96f4..6ad7abfcc95 100644 --- a/pkg/api/plugin_dashboards_test.go +++ b/pkg/api/plugin_dashboards_test.go @@ -41,7 +41,7 @@ func TestGetPluginDashboards(t *testing.T) { s := SetupAPITestServer(t, func(hs *HTTPServer) { hs.pluginDashboardService = pluginDashboardService - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run("Not signed in should return 404 Not Found", func(t *testing.T) { diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index e31a16c07c1..af7ec30e5ac 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -32,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" @@ -138,7 +139,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -151,7 +154,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[3] @@ -163,7 +168,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[4] @@ -174,7 +181,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) proxy.matchedRoute = routes[5] @@ -188,7 +197,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("Validating request", func(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -197,7 +208,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -207,7 +220,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is admin", func(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = org.RoleAdmin - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) err = proxy.validateRequest() @@ -298,7 +313,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, } - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -314,7 +331,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) client = newFakeHTTPClient(t, json2) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) @@ -331,7 +350,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, []byte{}) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) @@ -355,7 +376,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -382,7 +405,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -408,7 +433,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -438,7 +465,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -463,7 +492,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -514,7 +545,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -651,7 +684,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -671,7 +706,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -687,7 +724,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -711,7 +750,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -738,7 +779,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -764,7 +807,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -790,8 +835,11 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) - _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) + quotaService := quotatest.New(false, nil) + var err error + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) + _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) } @@ -812,8 +860,10 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) - _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) + _, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) } @@ -856,7 +906,9 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) if tc.err == nil { require.NoError(t, err) @@ -884,7 +936,9 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -1001,7 +1055,9 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets tracer := tracing.InitializeTracerForTest() var routes []*plugins.Route - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) @@ -1045,7 +1101,9 @@ func Test_PathCheck(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) proxy, err := NewDataSourceProxy(&datasources.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer) require.NoError(t, err) diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index b32d81f7365..5dad8f06d38 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -60,7 +60,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) { PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled, } hs.pluginInstaller = inst - hs.QuotaService = quotatest.NewQuotaServiceFake() + hs.QuotaService = quotatest.New(false, nil) }) t.Run(testName("Install", tc), func(t *testing.T) { diff --git a/pkg/api/quota.go b/pkg/api/quota.go index 9d3fa2a5c0b..dd0ee9f538d 100644 --- a/pkg/api/quota.go +++ b/pkg/api/quota.go @@ -6,10 +6,22 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) +// swagger:route GET /org/quotas getCurrentOrg getCurrentOrgQuota +// +// Fetch Organization quota. +// +// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `orgs.quotas:read` and scope `org:id:1` (orgIDScope). +// +// Responses: +// 200: getQuotaResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Response { return hs.getOrgQuotasHelper(c, c.OrgID) } @@ -29,22 +41,17 @@ func (hs *HTTPServer) GetCurrentOrgQuotas(c *models.ReqContext) response.Respons func (hs *HTTPServer) GetOrgQuotas(c *models.ReqContext) response.Response { orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "orgId is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) } return hs.getOrgQuotasHelper(c, orgId) } func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) response.Response { - if !hs.Cfg.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) + q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.OrgScope, orgID) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get quota", err) } - query := models.GetOrgQuotasQuery{OrgId: orgID} - - if err := hs.SQLStore.GetOrgQuotas(c.Req.Context(), &query); err != nil { - return response.Error(500, "Failed to get org quotas", err) - } - - return response.JSON(http.StatusOK, query.Result) + return response.JSON(http.StatusOK, q) } // swagger:route PUT /orgs/{org_id}/quotas/{quota_target} orgs updateOrgQuota @@ -63,26 +70,19 @@ func (hs *HTTPServer) getOrgQuotasHelper(c *models.ReqContext, orgID int64) resp // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { - cmd := models.UpdateOrgQuotaCmd{} + cmd := quota.UpdateQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) + return response.Err(quota.ErrBadRequest.Errorf("bad request data: %w", err)) } - if !hs.Cfg.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) - } - cmd.OrgId, err = strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) + cmd.OrgID, err = strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "orgId is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("orgId is invalid: %w", err)) } cmd.Target = web.Params(c.Req)[":target"] - if _, ok := hs.Cfg.Quota.Org.ToMap()[cmd.Target]; !ok { - return response.Error(404, "Invalid quota target", nil) - } - - if err := hs.SQLStore.UpdateOrgQuota(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to update org quotas", err) + if err := hs.QuotaService.Update(c.Req.Context(), &cmd); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update org quotas", err) } return response.Success("Organization quota updated") } @@ -114,22 +114,17 @@ func (hs *HTTPServer) UpdateOrgQuota(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { - if !setting.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) - } - id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "id is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) } - query := models.GetUserQuotasQuery{UserId: id} - - if err := hs.SQLStore.GetUserQuotas(c.Req.Context(), &query); err != nil { - return response.Error(500, "Failed to get org quotas", err) + q, err := hs.QuotaService.GetQuotasByScope(c.Req.Context(), quota.UserScope, id) + if err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to get org quotas", err) } - return response.JSON(http.StatusOK, query.Result) + return response.JSON(http.StatusOK, q) } // swagger:route PUT /admin/users/{user_id}/quotas/{quota_target} admin_users updateUserQuota @@ -148,26 +143,19 @@ func (hs *HTTPServer) GetUserQuotas(c *models.ReqContext) response.Response { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { - cmd := models.UpdateUserQuotaCmd{} + cmd := quota.UpdateQuotaCmd{} var err error if err := web.Bind(c.Req, &cmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) + return response.Err(quota.ErrBadRequest.Errorf("bad request data: %w", err)) } - if !setting.Quota.Enabled { - return response.Error(404, "Quotas not enabled", nil) - } - cmd.UserId, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) + cmd.UserID, err = strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64) if err != nil { - return response.Error(http.StatusBadRequest, "id is invalid", err) + return response.Err(quota.ErrBadRequest.Errorf("id is invalid: %w", err)) } cmd.Target = web.Params(c.Req)[":target"] - if _, ok := setting.Quota.User.ToMap()[cmd.Target]; !ok { - return response.Error(404, "Invalid quota target", nil) - } - - if err := hs.SQLStore.UpdateUserQuota(c.Req.Context(), &cmd); err != nil { - return response.Error(500, "Failed to update org quotas", err) + if err := hs.QuotaService.Update(c.Req.Context(), &cmd); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update org quotas", err) } return response.Success("Organization quota updated") } @@ -176,7 +164,7 @@ func (hs *HTTPServer) UpdateUserQuota(c *models.ReqContext) response.Response { type UpdateUserQuotaParams struct { // in:body // required:true - Body models.UpdateUserQuotaCmd `json:"body"` + Body quota.UpdateQuotaCmd `json:"body"` // in:path // required:true QuotaTarget string `json:"quota_target"` @@ -203,7 +191,7 @@ type GetOrgQuotaParams struct { type UpdateOrgQuotaParam struct { // in:body // required:true - Body models.UpdateOrgQuotaCmd `json:"body"` + Body quota.UpdateQuotaCmd `json:"body"` // in:path // required:true QuotaTarget string `json:"quota_target"` @@ -215,5 +203,5 @@ type UpdateOrgQuotaParam struct { // swagger:response getQuotaResponse type GetQuotaResponseResponse struct { // in:body - Body []*models.UserQuotaDTO `json:"body"` + Body []*quota.QuotaDTO `json:"body"` } diff --git a/pkg/api/quota_test.go b/pkg/api/quota_test.go index 51a6806a35f..36e128f9124 100644 --- a/pkg/api/quota_test.go +++ b/pkg/api/quota_test.go @@ -32,17 +32,13 @@ var testOrgQuota = setting.OrgQuota{ func setupDBAndSettingsForAccessControlQuotaTests(t *testing.T, sc accessControlScenarioContext) { t.Helper() - sc.hs.Cfg.Quota.Enabled = true - sc.hs.Cfg.Quota.Org = &testOrgQuota - // Required while sqlstore quota.go relies on setting global variables - setting.Quota = sc.hs.Cfg.Quota - // Create two orgs with the context user setupOrgsDBForAccessControlTests(t, sc.db, sc, 2) } func TestAPIEndpoint_GetCurrentOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() + cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -62,7 +58,9 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { - sc := setupHTTPServer(t, true) + cfg := setting.NewCfg() + cfg.Quota.Enabled = true + sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) setupDBAndSettingsForAccessControlQuotaTests(t, sc) @@ -86,6 +84,7 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { func TestAPIEndpoint_GetOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() + cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -105,7 +104,9 @@ func TestAPIEndpoint_GetOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { - sc := setupHTTPServer(t, true) + cfg := setting.NewCfg() + cfg.Quota.Enabled = true + sc := setupHTTPServerWithCfg(t, true, cfg) setupDBAndSettingsForAccessControlQuotaTests(t, sc) t.Run("AccessControl allows viewing another org quotas with correct permissions", func(t *testing.T) { @@ -130,6 +131,7 @@ func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { func TestAPIEndpoint_PutOrgQuotas_LegacyAccessControl(t *testing.T) { cfg := setting.NewCfg() + cfg.Quota.Enabled = true cfg.RBACEnabled = false sc := setupHTTPServerWithCfg(t, true, cfg) setInitCtxSignedInViewer(sc.initCtx) @@ -151,7 +153,20 @@ func TestAPIEndpoint_PutOrgQuotas_LegacyAccessControl(t *testing.T) { } func TestAPIEndpoint_PutOrgQuotas_AccessControl(t *testing.T) { - sc := setupHTTPServer(t, true) + cfg := setting.NewCfg() + cfg.Quota = setting.QuotaSettings{ + Enabled: true, + Global: setting.GlobalQuota{ + Org: 5, + }, + Org: setting.OrgQuota{ + User: 5, + }, + User: setting.UserQuota{ + Org: 5, + }, + } + sc := setupHTTPServerWithCfg(t, true, cfg) setupDBAndSettingsForAccessControlQuotaTests(t, sc) input := strings.NewReader(testUpdateOrgQuotaCmd) diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index cc3e8946e5e..e810cecc6c3 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/authinfoservice" authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/login/logintest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/secrets/database" @@ -71,7 +72,8 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } user, err := sqlStore.CreateUser(context.Background(), createUserCmd) require.Nil(t, err) - hs.userService = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil) + hs.userService, err = userimpl.ProvideService(sqlStore, nil, sc.cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) sc.handlerFunc = hs.GetUserByID diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 366e0137675..82efb1ce69e 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -254,7 +254,7 @@ var wireSet = wire.NewSet( wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, auth.ProvideActiveAuthTokenService, - wire.Bind(new(models.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/middleware/quota.go b/pkg/middleware/quota.go index 57533436ebe..7a0689ff11d 100644 --- a/pkg/middleware/quota.go +++ b/pkg/middleware/quota.go @@ -14,15 +14,15 @@ func Quota(quotaService quota.Service) func(string) web.Handler { panic("quotaService is nil") } //https://open.spotify.com/track/7bZSoBEAEEUsGEuLOf94Jm?si=T1Tdju5qRSmmR0zph_6RBw fuuuuunky - return func(target string) web.Handler { + return func(targetSrv string) web.Handler { return func(c *models.ReqContext) { - limitReached, err := quotaService.QuotaReached(c, target) + limitReached, err := quotaService.QuotaReached(c, quota.TargetSrv(targetSrv)) if err != nil { c.JsonApiErr(500, "Failed to get quota", err) return } if limitReached { - c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", target), nil) + c.JsonApiErr(403, fmt.Sprintf("%s Quota reached", targetSrv), nil) return } } diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 3f0aacd89ab..446b7842933 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" @@ -30,8 +30,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.User = 4 }) middlewareScenario(t, "and global session quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -41,8 +39,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.Session = 10 }) middlewareScenario(t, "and global session quota reached", func(t *testing.T, sc *scenarioContext) { @@ -52,13 +48,10 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.Session = 1 }) }) t.Run("with user logged in", func(t *testing.T) { - const quotaUsed = 4 setUp := func(sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{UserID: 12} @@ -79,8 +72,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Global.DataSource = quotaUsed }) middlewareScenario(t, "user Org quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -93,8 +84,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.User.Org = quotaUsed + 1 }) middlewareScenario(t, "user Org quota reached", func(t *testing.T, sc *scenarioContext) { @@ -106,8 +95,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.User.Org = quotaUsed }) middlewareScenario(t, "org dashboard quota not reached", func(t *testing.T, sc *scenarioContext) { @@ -119,8 +106,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.Dashboard = quotaUsed + 1 }) middlewareScenario(t, "org dashboard quota reached", func(t *testing.T, sc *scenarioContext) { @@ -132,8 +117,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.Dashboard = quotaUsed }) middlewareScenario(t, "org dashboard quota reached, but quotas disabled", func(t *testing.T, sc *scenarioContext) { @@ -145,9 +128,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.Dashboard = quotaUsed - cfg.Quota.Enabled = false }) middlewareScenario(t, "org alert quota reached and unified alerting is enabled", func(t *testing.T, sc *scenarioContext) { @@ -162,7 +142,6 @@ func TestMiddlewareQuota(t *testing.T) { cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true - cfg.Quota.Org.AlertRule = quotaUsed }) middlewareScenario(t, "org alert quota not reached and unified alerting is enabled", func(t *testing.T, sc *scenarioContext) { @@ -177,7 +156,6 @@ func TestMiddlewareQuota(t *testing.T) { cfg.UnifiedAlerting.Enabled = new(bool) *cfg.UnifiedAlerting.Enabled = true - cfg.Quota.Org.AlertRule = quotaUsed + 1 }) middlewareScenario(t, "org alert quota reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { @@ -190,8 +168,6 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 403, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.AlertRule = quotaUsed }) middlewareScenario(t, "org alert quota not reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { @@ -203,58 +179,15 @@ func TestMiddlewareQuota(t *testing.T) { assert.Equal(t, 200, sc.resp.Code) }, func(cfg *setting.Cfg) { configure(cfg) - - cfg.Quota.Org.AlertRule = quotaUsed + 1 }) }) } func getQuotaHandler(reached bool, target string) web.Handler { - qs := &mockQuotaService{ - reached: reached, - } + qs := quotatest.New(reached, nil) return Quota(qs)(target) } func configure(cfg *setting.Cfg) { cfg.AnonymousEnabled = false - cfg.Quota = setting.QuotaSettings{ - Enabled: true, - Org: &setting.OrgQuota{ - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - AlertRule: 5, - }, - User: &setting.UserQuota{ - Org: 5, - }, - Global: &setting.GlobalQuota{ - Org: 5, - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - Session: 5, - AlertRule: 5, - }, - } -} - -type mockQuotaService struct { - reached bool - err error -} - -func (m *mockQuotaService) QuotaReached(c *models.ReqContext, target string) (bool, error) { - return m.reached, m.err -} - -func (m *mockQuotaService) CheckQuotaReached(c context.Context, target string, params *quota.ScopeParameters) (bool, error) { - return m.reached, m.err -} - -func (m *mockQuotaService) DeleteByUser(c context.Context, userID int64) error { - return m.err } diff --git a/pkg/models/quotas.go b/pkg/models/quotas.go deleted file mode 100644 index 26a63a92423..00000000000 --- a/pkg/models/quotas.go +++ /dev/null @@ -1,91 +0,0 @@ -package models - -import ( - "errors" - "time" -) - -var ErrInvalidQuotaTarget = errors.New("invalid quota target") - -type Quota struct { - Id int64 - OrgId int64 - UserId int64 - Target string - Limit int64 - Created time.Time - Updated time.Time -} - -type QuotaScope struct { - Name string - Target string - DefaultLimit int64 -} - -type OrgQuotaDTO struct { - OrgId int64 `json:"org_id"` - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` -} - -type UserQuotaDTO struct { - UserId int64 `json:"user_id"` - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` -} - -type GlobalQuotaDTO struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - Used int64 `json:"used"` -} - -type GetOrgQuotaByTargetQuery struct { - Target string - OrgId int64 - Default int64 - UnifiedAlertingEnabled bool - Result *OrgQuotaDTO -} - -type GetOrgQuotasQuery struct { - OrgId int64 - UnifiedAlertingEnabled bool - Result []*OrgQuotaDTO -} - -type GetUserQuotaByTargetQuery struct { - Target string - UserId int64 - Default int64 - UnifiedAlertingEnabled bool - Result *UserQuotaDTO -} - -type GetUserQuotasQuery struct { - UserId int64 - UnifiedAlertingEnabled bool - Result []*UserQuotaDTO -} - -type GetGlobalQuotaByTargetQuery struct { - Target string - Default int64 - UnifiedAlertingEnabled bool - Result *GlobalQuotaDTO -} - -type UpdateOrgQuotaCmd struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - OrgId int64 `json:"-"` -} - -type UpdateUserQuotaCmd struct { - Target string `json:"target"` - Limit int64 `json:"limit"` - UserId int64 `json:"-"` -} diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 6ce74c004f3..6c92a40d86b 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -76,10 +76,6 @@ type UserTokenService interface { GetUserRevokedTokens(ctx context.Context, userId int64) ([]*UserToken, error) } -type ActiveTokenService interface { - ActiveTokenCount(ctx context.Context) (int64, error) -} - type UserTokenBackgroundService interface { registry.BackgroundService } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 372d590faf1..57338f5d46c 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -272,7 +272,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, auth.ProvideActiveAuthTokenService, - wire.Bind(new(models.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index 7c033d2f4a8..c1352b89f9b 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -225,7 +226,8 @@ func setupTestEnvironment(t *testing.T, permissions []accesscontrol.Permission, sql := db.InitTestDB(t) cfg := setting.NewCfg() teamSvc := teamimpl.ProvideService(sql, cfg) - userSvc := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil) + userSvc, err := userimpl.ProvideService(sql, nil, cfg, teamimpl.ProvideService(sql, cfg), nil, quotatest.New(false, nil)) + require.NoError(t, err) license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() mock := accesscontrolmock.New().WithPermissions(permissions) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index e7615243fdc..44239e50f95 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -56,7 +57,9 @@ func TestIntegrationAnnotations(t *testing.T) { assert.NoError(t, err) }) - dashboardStore := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) + require.NoError(t, err) testDashboard1 := models.SaveDashboardCommand{ UserId: 1, @@ -453,7 +456,9 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { var maximumTagsLength int64 = 60 repo := xormRepositoryImpl{db: sql, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(sql, sql.Cfg), maximumTagsLength: maximumTagsLength} - dashboardStore := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) + require.NoError(t, err) testDashboard1 := models.SaveDashboardCommand{ UserId: 1, diff --git a/pkg/services/apikey/apikeyimpl/apikey.go b/pkg/services/apikey/apikeyimpl/apikey.go index 4b2af715707..2a09d26319f 100644 --- a/pkg/services/apikey/apikeyimpl/apikey.go +++ b/pkg/services/apikey/apikeyimpl/apikey.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" ) @@ -13,16 +14,34 @@ type Service struct { store store } -func ProvideService(db db.DB, cfg *setting.Cfg) apikey.Service { +func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (apikey.Service, error) { + s := &Service{} if cfg.IsFeatureToggleEnabled(featuremgmt.FlagNewDBLibrary) { - return &Service{ - store: &sqlxStore{ - sess: db.GetSqlxSession(), - cfg: cfg, - }, + s.store = &sqlxStore{ + sess: db.GetSqlxSession(), + cfg: cfg, } } - return &Service{store: &sqlStore{db: db, cfg: cfg}} + s.store = &sqlStore{db: db, cfg: cfg} + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: apikey.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return s, err + } + + return s, nil +} + +func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return s.store.Count(ctx, scopeParams) } func (s *Service) GetAPIKeys(ctx context.Context, query *apikey.GetApiKeysQuery) error { @@ -49,3 +68,24 @@ func (s *Service) AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error { func (s *Service) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error { return s.store.UpdateAPIKeyLastUsedDate(ctx, tokenID) } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.ApiKey) + limits.Set(orgQuotaTag, cfg.Quota.Org.ApiKey) + return limits, nil +} diff --git a/pkg/services/apikey/apikeyimpl/sqlx_store.go b/pkg/services/apikey/apikeyimpl/sqlx_store.go index 9401a975931..b9935a58123 100644 --- a/pkg/services/apikey/apikeyimpl/sqlx_store.go +++ b/pkg/services/apikey/apikeyimpl/sqlx_store.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/setting" ) @@ -142,3 +143,35 @@ func (ss *sqlxStore) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64 _, err := ss.sess.Exec(ctx, `UPDATE api_key SET last_used_at=? WHERE id=?`, &now, tokenID) return err } + +func (ss *sqlxStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key`); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key WHERE org_id = ?`, scopeParams.OrgID); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} diff --git a/pkg/services/apikey/apikeyimpl/store.go b/pkg/services/apikey/apikeyimpl/store.go index 33b8159e7cc..54988660d08 100644 --- a/pkg/services/apikey/apikeyimpl/store.go +++ b/pkg/services/apikey/apikeyimpl/store.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/quota" ) type store interface { @@ -15,4 +16,6 @@ type store interface { GetApiKeyByName(ctx context.Context, query *apikey.GetByNameQuery) error GetAPIKeyByHash(ctx context.Context, hash string) (*apikey.APIKey, error) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error + + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } diff --git a/pkg/services/apikey/apikeyimpl/xorm_store.go b/pkg/services/apikey/apikeyimpl/xorm_store.go index fad3bb89401..bf2ba4ce6d4 100644 --- a/pkg/services/apikey/apikeyimpl/xorm_store.go +++ b/pkg/services/apikey/apikeyimpl/xorm_store.go @@ -11,6 +11,8 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -174,3 +176,47 @@ func (ss *sqlStore) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) return nil }) } + +func (ss *sqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM api_key" + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM api_key WHERE org_id = ?" + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, quota.OrgScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} diff --git a/pkg/services/apikey/model.go b/pkg/services/apikey/model.go index 82acaf3b77e..9563377760b 100644 --- a/pkg/services/apikey/model.go +++ b/pkg/services/apikey/model.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -64,3 +65,8 @@ type GetByIDQuery struct { ApiKeyId int64 Result *APIKey } + +const ( + QuotaTargetSrv quota.TargetSrv = "api_key" + QuotaTarget quota.Target = "api_key" +) diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index dfbd80c8064..f261e33bcd1 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -41,19 +42,38 @@ type UserAuthTokenService struct { log log.Logger } +type ActiveTokenService interface { + ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) +} + type ActiveAuthTokenService struct { cfg *setting.Cfg sqlStore db.DB } -func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB) *ActiveAuthTokenService { - return &ActiveAuthTokenService{ +func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaService quota.Service) (*ActiveAuthTokenService, error) { + s := &ActiveAuthTokenService{ cfg: cfg, sqlStore: sqlStore, } + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.ActiveTokenCount, + }); err != nil { + return s, err + } + + return s, nil } -func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, error) { +func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { var count int64 var err error err = a.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { @@ -66,7 +86,14 @@ func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, e return err }) - return count, err + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u := "a.Map{} + u.Set(tag, count) + + return u, err } func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { @@ -472,3 +499,19 @@ func hashToken(token string) string { hashBytes := sha256.Sum256([]byte(token + setting.SecretKey)) return hex.EncodeToString(hashBytes[:]) } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.Session) + return limits, nil +} diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index a2e86b79e42..16886d7b439 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -40,8 +41,12 @@ func TestUserAuthToken(t *testing.T) { userToken := createToken() t.Run("Can count active tokens", func(t *testing.T) { - count, err := ctx.activeTokenService.ActiveTokenCount(context.Background()) + m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + require.NoError(t, err) + count, ok := m.Get(tag) + require.True(t, ok) require.Equal(t, int64(1), count) }) @@ -208,8 +213,12 @@ func TestUserAuthToken(t *testing.T) { require.Nil(t, notGood) t.Run("should not find active token when expired", func(t *testing.T) { - count, err := ctx.activeTokenService.ActiveTokenCount(context.Background()) + m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + require.NoError(t, err) + count, ok := m.Get(tag) + require.True(t, ok) require.Equal(t, int64(0), count) }) }) diff --git a/pkg/services/auth/model.go b/pkg/services/auth/model.go index 799b3e68b16..afc5b566c48 100644 --- a/pkg/services/auth/model.go +++ b/pkg/services/auth/model.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" ) type userAuthToken struct { @@ -71,3 +72,8 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { return nil } + +const ( + QuotaTargetSrv quota.TargetSrv = "auth" + QuotaTarget quota.Target = "session" +) diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index 12691f8ed5e..f491d645bdc 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" ) @@ -64,9 +65,9 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re return response.Error(http.StatusUnprocessableEntity, "Dashboard must be set", nil) } - limitReached, err := api.quotaService.QuotaReached(c, "dashboard") + limitReached, err := api.quotaService.QuotaReached(c, dashboards.QuotaTargetSrv) if err != nil { - return response.Error(500, "failed to get quota", err) + return response.Err(err) } if limitReached { @@ -83,12 +84,12 @@ func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Re } type QuotaService interface { - QuotaReached(c *models.ReqContext, target string) (bool, error) + QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) } -type quotaServiceFunc func(c *models.ReqContext, target string) (bool, error) +type quotaServiceFunc func(c *models.ReqContext, target quota.TargetSrv) (bool, error) -func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target string) (bool, error) { +func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return fn(c, target) } diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 77085c0c01a..d688e019109 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web/webtest" "github.com/stretchr/testify/require" @@ -165,10 +166,10 @@ func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport. return nil, nil } -func quotaReached(c *models.ReqContext, target string) (bool, error) { +func quotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return true, nil } -func quotaNotReached(c *models.ReqContext, target string) (bool, error) { +func quotaNotReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return false, nil } diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index c8f80d0b7a1..6493b51b9d7 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/quota" ) // DashboardService is a service for operating on dashboards. @@ -78,6 +79,7 @@ type Store interface { ValidateDashboardBeforeSave(ctx context.Context, dashboard *models.Dashboard, overwrite bool) (bool, error) DeleteACLByUser(context.Context, int64) error + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) // CountDashboardsInFolder returns the number of dashboards associated with // the given parent folder ID. CountDashboardsInFolder(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) diff --git a/pkg/services/dashboards/database/acl_test.go b/pkg/services/dashboards/database/acl_test.go index 3836bfdb01e..86ef2df3e3a 100644 --- a/pkg/services/dashboards/database/acl_test.go +++ b/pkg/services/dashboards/database/acl_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" @@ -26,7 +27,10 @@ func TestIntegrationDashboardACLDataAccess(t *testing.T) { setup := func(t *testing.T) { sqlStore = db.InitTestDB(t) - dashboardStore = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) currentUser = createUser(t, sqlStore, "viewer", "Viewer", false) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") childDash = insertTestDashboard(t, dashboardStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp") diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 640274db692..faf257c7195 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -17,6 +17,8 @@ import ( dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -43,8 +45,23 @@ type DashboardTag struct { // DashboardStore implements the Store interface var _ dashboards.Store = (*DashboardStore)(nil) -func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service) *DashboardStore { - return &DashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} +func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (*DashboardStore, error) { + s := &DashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return nil, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: dashboards.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Count, + }); err != nil { + return nil, err + } + + return s, nil } func (d *DashboardStore) emitEntityEvent() bool { @@ -292,6 +309,50 @@ func (d *DashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context }) } +func (d *DashboardStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.GetDialect().BooleanStr(false)) + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.GetDialect().BooleanStr(false)) + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) + if err != nil { + return nil, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} + func getExistingDashboardByIdOrUidForUpdate(sess *db.Session, dash *models.Dashboard, dialect migrator.Dialect, overwrite bool) (bool, error) { dashWithIdExists := false isParentFolderChanged := false @@ -1036,3 +1097,24 @@ func (d *DashboardStore) CountDashboardsInFolder( }) return count, err } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope) + if err != nil { + return "a.Map{}, err + } + orgQuotaTag, err := quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope) + if err != nil { + return "a.Map{}, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.Dashboard) + limits.Set(orgQuotaTag, cfg.Quota.Org.Dashboard) + return limits, nil +} diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index b79508f1cd4..140fec35efb 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -33,7 +34,10 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup := func() { sqlStore = db.InitTestDB(t) sqlStore.Cfg.RBACEnabled = false - dashboardStore = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp") childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, folder.Id, false, "prod", "webapp") @@ -186,7 +190,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2 := func() { sqlStore = db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod") @@ -291,7 +297,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup3 := func() { sqlStore = db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod") insertTestDashboard(t, dashboardStore, "folder in another org", 2, 0, true, "prod") @@ -473,7 +481,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { var sqlStore *sqlstore.SQLStore var folder1, folder2 *models.Dashboard sqlStore = db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder2 = insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") _ = insertTestDashboard(t, dashboardStore, title, orgId, folder2.Id, false, "prod") folder1 = insertTestDashboard(t, dashboardStore, title, orgId, 0, true, "prod") @@ -488,7 +498,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByUID", func(t *testing.T) { var orgId int64 = 1 sqlStore := db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder := insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.Id, false, "prod") @@ -512,7 +524,9 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("GetFolderByID", func(t *testing.T) { var orgId int64 = 1 sqlStore := db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folder := insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod") dash := insertTestDashboard(t, dashboardStore, "Very Unique Name", orgId, folder.Id, false, "prod") diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index 35e7d8e18de..2bfd0feb0cf 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" ) @@ -18,7 +19,9 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { t.Skip("skipping integration test") } sqlStore := db.InitTestDB(t) - dashboardStore := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) folderCmd := models.SaveDashboardCommand{ OrgId: 1, diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 5163e0d8d90..4f63c4aef3d 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/publicdashboards/database" publicDashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/star" @@ -42,7 +43,10 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) starService = starimpl.ProvideService(sqlStore, cfg) - dashboardStore = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp") insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.Id, false, "prod") @@ -585,7 +589,9 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - dashboardStore := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) pluginId := "test-app" appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId) @@ -597,7 +603,7 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test OrgId: 1, } - err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query) + err = dashboardStore.GetDashboardsByPluginID(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) } @@ -609,7 +615,9 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - dashboardStore := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) dashA := insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) @@ -660,7 +668,9 @@ func TestIntegrationDashboard_Filter(t *testing.T) { sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.IsFeatureToggleEnabled = func(key string) bool { return false } - dashboardStore := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false) dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false) qNoFilter := &models.FindPersistedDashboardsQuery{ diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index a73dfb2457e..f35cfcd686a 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -4,6 +4,7 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -30,6 +31,11 @@ type DashboardSearchProjection struct { SortMeta int64 } +const ( + QuotaTargetSrv quota.TargetSrv = "dashboard" + QuotaTarget quota.Target = "dashboard" +) + type CountDashboardsInFolderQuery struct { FolderUID string OrgID int64 diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index 5c5c369d864..210085b565d 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -42,7 +43,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { }), } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardNotFound, err) }) @@ -62,7 +63,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: false, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardNotFound, err) }) @@ -104,7 +105,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sqlStore) + err := callSaveWithError(t, cmd, sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, int64(0), sc.dashboardGuardianMock.DashId) @@ -124,7 +125,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.otherSavedFolder.Id, sc.dashboardGuardianMock.DashId) @@ -144,7 +145,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -165,7 +166,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -186,7 +187,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -207,7 +208,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -228,7 +229,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -249,7 +250,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -270,7 +271,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInGeneralFolder.Id, sc.dashboardGuardianMock.DashId) @@ -291,7 +292,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: true, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) assert.Equal(t, sc.savedDashInFolder.Id, sc.dashboardGuardianMock.DashId) @@ -432,7 +433,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardFolderNotFound, err) }) @@ -448,7 +449,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) }) @@ -488,7 +489,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) }) @@ -527,7 +528,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) @@ -543,7 +544,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) @@ -559,7 +560,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameInFolderExists, err) }) }) @@ -647,7 +648,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err) }) @@ -711,7 +712,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -727,7 +728,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -743,7 +744,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -759,7 +760,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) }) @@ -774,7 +775,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardWithSameNameAsFolder, err) }) @@ -789,7 +790,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { Overwrite: shouldOverwrite, } - err := callSaveWithError(cmd, sc.sqlStore) + err := callSaveWithError(t, cmd, sc.sqlStore) assert.Equal(t, dashboards.ErrDashboardFolderWithSameNameAsDashboard, err) }) }) @@ -821,7 +822,9 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled sqlStore := db.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -878,7 +881,9 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -892,12 +897,14 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore return res } -func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore db.DB) error { +func callSaveWithError(t *testing.T, cmd models.SaveDashboardCommand, sqlStore db.DB) error { dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -905,7 +912,7 @@ func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore db.DB) error { accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.New(), ) - _, err := service.SaveDashboard(context.Background(), &dto, false) + _, err = service.SaveDashboard(context.Background(), &dto, false) return err } @@ -934,7 +941,9 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), @@ -972,7 +981,9 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *mo cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) service := ProvideDashboardService( cfg, dashboardStore, &dummyDashAlertExtractor{}, featuremgmt.WithFeatures(), diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 309152a23ed..9cdd1a9aa41 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -5,8 +5,9 @@ package dashboards import ( context "context" - folder "github.com/grafana/grafana/pkg/services/folder" models "github.com/grafana/grafana/pkg/models" + folder "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" ) @@ -474,6 +475,10 @@ type mockConstructorTestingTNewFakeDashboardStore interface { Cleanup(func()) } +func (_m *FakeDashboardStore) Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) { + return nil, nil +} + // NewFakeDashboardStore creates a new instance of FakeDashboardStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. func NewFakeDashboardStore(t mockConstructorTestingTNewFakeDashboardStore) *FakeDashboardStore { mock := &FakeDashboardStore{} diff --git a/pkg/services/datasources/models.go b/pkg/services/datasources/models.go index 9697c739cbc..fec4db4ade7 100644 --- a/pkg/services/datasources/models.go +++ b/pkg/services/datasources/models.go @@ -4,6 +4,7 @@ import ( "time" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) @@ -193,3 +194,8 @@ type DatasourcesPermissionFilterQuery struct { Datasources []*DataSource Result []*DataSource } + +const ( + QuotaTargetSrv quota.TargetSrv = "data_source" + QuotaTarget quota.Target = "data_source" +) diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 064a3431029..3b4bb78e01e 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/kvstore" "github.com/grafana/grafana/pkg/setting" @@ -52,7 +53,8 @@ type cachedRoundTripper struct { func ProvideService( db db.DB, secretsService secrets.Service, secretsStore kvstore.SecretsKVStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService, -) *Service { + quotaService quota.Service, +) (*Service, error) { dslogger := log.New("datasources") store := &SqlStore{db: db, logger: dslogger} s := &Service{ @@ -73,7 +75,23 @@ func ProvideService( ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store)) ac.RegisterScopeAttributeResolver(NewIDScopeResolver(store)) - return s + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return nil, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: datasources.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return nil, err + } + return s, nil +} + +func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return s.SQLStore.Count(ctx, scopeParams) } // DataSourceRetriever interface for retrieving a datasource. @@ -591,3 +609,24 @@ func (s *Service) fillWithSecureJSONData(ctx context.Context, cmd *datasources.U return nil } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.OrgScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.DataSource) + limits.Set(orgQuotaTag, cfg.Quota.Org.DataSource) + return limits, nil +} diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index e12e4c3ac56..b06cb9913fa 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -21,6 +21,7 @@ import ( acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" @@ -200,7 +201,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) require.NoError(t, err) @@ -235,7 +238,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -284,7 +289,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -330,7 +337,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -373,7 +382,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -406,7 +417,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, @@ -473,7 +486,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Id: 1, Url: "http://k8s:8001", @@ -507,7 +522,9 @@ func TestService_GetHttpTransport(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) ds := datasources.DataSource{ Type: datasources.DS_ES, @@ -544,7 +561,9 @@ func TestService_getTimeout(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) for _, tc := range testCases { ds := &datasources.DataSource{ @@ -565,7 +584,9 @@ func TestService_GetDecryptedValues(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) jsonData := map[string]string{ "password": "securePassword", @@ -591,7 +612,9 @@ func TestService_GetDecryptedValues(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) - dsService := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) jsonData := map[string]string{ "password": "securePassword", diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index 2074889ce64..9737da64622 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -16,6 +16,8 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" ) @@ -29,6 +31,8 @@ type Store interface { AddDataSource(context.Context, *datasources.AddDataSourceCommand) error UpdateDataSource(context.Context, *datasources.UpdateDataSourceCommand) error GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) error + + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } type SqlStore struct { @@ -171,6 +175,50 @@ func (ss *SqlStore) DeleteDataSource(ctx context.Context, cmd *datasources.Delet }) } +func (ss *SqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM data_source" + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) AS count FROM data_source WHERE org_id=?" + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, quota.OrgScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} + func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataSourceCommand) error { return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { existing := datasources.DataSource{OrgId: cmd.OrgId, Name: cmd.Name} diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 63f9587d122..6a6dcb85182 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" ) @@ -584,7 +585,8 @@ func TestIntegrationGetChildren(t *testing.T) { func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { t.Helper() - orgService := orgimpl.ProvideService(db, db.Cfg) + orgService, err := orgimpl.ProvideService(db, db.Cfg, quotatest.New(false, nil)) + require.NoError(t, err) orgID, err := orgService.GetOrCreate(context.Background(), "test-org") require.NoError(t, err) t.Cleanup(func() { diff --git a/pkg/services/guardian/accesscontrol_guardian_test.go b/pkg/services/guardian/accesscontrol_guardian_test.go index 8660e1cf2b5..39c0496a19c 100644 --- a/pkg/services/guardian/accesscontrol_guardian_test.go +++ b/pkg/services/guardian/accesscontrol_guardian_test.go @@ -19,6 +19,7 @@ import ( dashdb "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" @@ -591,7 +592,9 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce toSave.SetUid(uid) // seed dashboard - dashStore := dashdb.ProvideDashboardStore(store, store.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(store, store.Cfg)) + quotaService := quotatest.New(false, nil) + dashStore, err := dashdb.ProvideDashboardStore(store, store.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(store, store.Cfg), quotaService) + require.NoError(t, err) dash, err := dashStore.SaveDashboard(context.Background(), models.SaveDashboardCommand{ Dashboard: toSave.Data, UserId: 1, @@ -603,7 +606,8 @@ func setupAccessControlGuardianTest(t *testing.T, uid string, permissions []acce license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() teamSvc := teamimpl.ProvideService(store, store.Cfg) - userSvc := userimpl.ProvideService(store, nil, store.Cfg, nil, nil) + userSvc, err := userimpl.ProvideService(store, nil, store.Cfg, nil, nil, quotatest.New(false, nil)) + require.NoError(t, err) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( setting.NewCfg(), routing.NewRouteRegister(), store, ac, license, &dashboards.FakeDashboardStore{}, ac, teamSvc, userSvc) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 944dfeb650e..0a273577558 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -280,7 +281,9 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash cfg.RBACEnabled = false features := featuremgmt.WithFeatures() cfg.IsFeatureToggleEnabled = features.IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) dashAlertExtractor := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := acmock.New() folderPermissions := acmock.NewMockedPermissionsService() @@ -306,7 +309,9 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user user.S ac := acmock.New() folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) d := dashboardservice.ProvideDashboardService( cfg, dashboardStore, nil, @@ -423,7 +428,9 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo webCtx := web.Context{Req: req} sqlStore := db.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) features := featuremgmt.WithFeatures() ac := acmock.New().WithDisabled() // TODO: Update tests to work with rbac @@ -450,7 +457,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Login: userInDbName, } - _, err := sqlStore.CreateUser(context.Background(), cmd) + _, err = sqlStore.CreateUser(context.Background(), cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 7f7bf97bebd..202f09eae26 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/team/teamtest" "github.com/grafana/grafana/pkg/services/user" @@ -693,7 +694,9 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash cfg := setting.NewCfg() cfg.RBACEnabled = false cfg.IsFeatureToggleEnabled = featuremgmt.WithFeatures().IsEnabled - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) dashAlertService := alerting.ProvideDashAlertExtractorService(nil, nil, nil) ac := acmock.New() service := dashboardservice.ProvideDashboardService( @@ -717,7 +720,9 @@ func createFolderWithACL(t *testing.T, sqlStore db.DB, title string, user *user. features := featuremgmt.WithFeatures() folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) d := dashboardservice.ProvideDashboardService(cfg, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac) s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, d, dashboardStore, nil, features, folderPermissions, nil) @@ -811,7 +816,9 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo orgID := int64(1) role := org.RoleAdmin sqlStore, cfg := db.InitTestDBwithCfg(t) - dashboardStore := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) features := featuremgmt.WithFeatures() ac := acmock.New() @@ -852,7 +859,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo ctx := appcontext.WithUser(context.Background(), usr) - _, err := sqlStore.CreateUser(ctx, cmd) + _, err = sqlStore.CreateUser(ctx, cmd) require.NoError(t, err) sc := scenarioContext{ diff --git a/pkg/services/login/loginservice/loginservice.go b/pkg/services/login/loginservice/loginservice.go index 6e68e00c0a5..1c28ac1423c 100644 --- a/pkg/services/login/loginservice/loginservice.go +++ b/pkg/services/login/loginservice/loginservice.go @@ -71,13 +71,17 @@ func (ls *Implementation) UpsertUser(ctx context.Context, cmd *models.UpsertUser return login.ErrSignupNotAllowed } - limitReached, errLimit := ls.QuotaService.QuotaReached(cmd.ReqContext, "user") - if errLimit != nil { - cmd.ReqContext.Logger.Warn("Error getting user quota.", "error", errLimit) - return login.ErrGettingUserQuota - } - if limitReached { - return login.ErrUsersQuotaReached + // we may insert in both user and org_user tables + // therefore we need to query check quota for both user and org services + for _, srv := range []string{user.QuotaTargetSrv, org.QuotaTargetSrv} { + limitReached, errLimit := ls.QuotaService.QuotaReached(cmd.ReqContext, quota.TargetSrv(srv)) + if errLimit != nil { + cmd.ReqContext.Logger.Warn("Error getting user quota.", "error", errLimit) + return login.ErrGettingUserQuota + } + if limitReached { + return login.ErrUsersQuotaReached + } } result, errCreateUser := ls.createUser(extUser) diff --git a/pkg/services/login/loginservice/loginservice_test.go b/pkg/services/login/loginservice/loginservice_test.go index 2655a7d5c3a..edd9bade8d6 100644 --- a/pkg/services/login/loginservice/loginservice_test.go +++ b/pkg/services/login/loginservice/loginservice_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/stretchr/testify/assert" @@ -26,7 +26,7 @@ func Test_syncOrgRoles_doesNotBreakWhenTryingToRemoveLastOrgAdmin(t *testing.T) authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - QuotaService: "aimpl.Service{}, + QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, SQLStore: nil, userService: usertest.NewUserServiceFake(), @@ -51,7 +51,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { orgService.ExpectedOrgListResponse = createResponseWithOneErrLastOrgAdminItem() login := Implementation{ - QuotaService: "aimpl.Service{}, + QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, SQLStore: nil, userService: usertest.NewUserServiceFake(), @@ -66,7 +66,7 @@ func Test_syncOrgRoles_whenTryingToRemoveLastOrgLogsError(t *testing.T) { func Test_teamSync(t *testing.T) { authInfoMock := &logintest.AuthInfoServiceFake{} login := Implementation{ - QuotaService: "aimpl.Service{}, + QuotaService: quotatest.New(false, nil), AuthInfoService: authInfoMock, } diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index bf0c3953c8f..ffca5f0c293 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -145,3 +145,28 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { alertRules: api.AlertRules, }), m) } + +func (api *API) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + if orgUsage, err := api.RuleStore.Count(ctx, scopeParams.OrgID); err != nil { + return u, err + } else { + tag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.OrgScope) + if err != nil { + return u, err + } + u.Set(tag, orgUsage) + } + + if globalUsage, err := api.RuleStore.Count(ctx, 0); err != nil { + return u, err + } else { + tag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, globalUsage) + } + + return u, nil +} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 6161bce3bb4..5b195599241 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -393,7 +393,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod } if len(finalChanges.New) > 0 { - limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{ + limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, ngmodels.QuotaTargetSrv, "a.ScopeParameters{ OrgID: c.OrgID, UserID: c.UserID, }) // alert rule is table name diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 6c3b03576c3..10c7e3747c7 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -23,4 +23,6 @@ type RuleStore interface { // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) + + Count(ctx context.Context, orgID int64) (int64, error) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index c2573b57bd3..53b3f19a25e 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -489,6 +490,11 @@ func (g RulesGroup) SortByGroupIndex() { }) } +const ( + QuotaTargetSrv quota.TargetSrv = "ngalert" + QuotaTarget quota.Target = "alert_rule" +) + type ruleKeyContextKey struct{} func WithRuleKey(ctx context.Context, ruleKey AlertRuleKey) context.Context { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index d61b07fe9c0..12cc93d6ca2 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -239,6 +239,19 @@ func (ng *AlertNG) init() error { } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) + defaultLimits, err := readQuotaConfig(ng.Cfg) + if err != nil { + return err + } + + if err := ng.QuotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: models.QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: api.Usage, + }); err != nil { + return err + } + log.RegisterContextualLogProvider(func(ctx context.Context) ([]interface{}, bool) { key, ok := models.RuleKeyFromContext(ctx) if !ok { @@ -308,3 +321,32 @@ func (ng *AlertNG) IsDisabled() bool { } return !ng.Cfg.UnifiedAlerting.IsEnabled() } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + var alertOrgQuota int64 + var alertGlobalQuota int64 + + if cfg.UnifiedAlerting.IsEnabled() { + alertOrgQuota = cfg.Quota.Org.AlertRule + alertGlobalQuota = cfg.Quota.Global.AlertRule + } + + globalQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.OrgScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, alertGlobalQuota) + limits.Set(orgQuotaTag, alertOrgQuota) + return limits, nil +} diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 97d406a8214..bfbbadb3646 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -48,7 +48,7 @@ type RuleStore interface { // //go:generate mockery --name QuotaChecker --structname MockQuotaChecker --inpackage --filename quota_checker_mock.go --with-expecter type QuotaChecker interface { - CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) + CheckQuotaReached(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) } // PersistConfig validates to config before eventually persisting it if no error occurs diff --git a/pkg/services/ngalert/provisioning/quota_checker_mock.go b/pkg/services/ngalert/provisioning/quota_checker_mock.go index f545dd1b5ec..1dac163c33d 100644 --- a/pkg/services/ngalert/provisioning/quota_checker_mock.go +++ b/pkg/services/ngalert/provisioning/quota_checker_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.12.0. DO NOT EDIT. +// Code generated by mockery v2.14.0. DO NOT EDIT. package provisioning @@ -7,8 +7,6 @@ import ( quota "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" - - testing "testing" ) // MockQuotaChecker is an autogenerated mock type for the QuotaChecker type @@ -25,18 +23,18 @@ func (_m *MockQuotaChecker) EXPECT() *MockQuotaChecker_Expecter { } // CheckQuotaReached provides a mock function with given fields: ctx, target, scopeParams -func (_m *MockQuotaChecker) CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) { +func (_m *MockQuotaChecker) CheckQuotaReached(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { ret := _m.Called(ctx, target, scopeParams) var r0 bool - if rf, ok := ret.Get(0).(func(context.Context, string, *quota.ScopeParameters) bool); ok { + if rf, ok := ret.Get(0).(func(context.Context, quota.TargetSrv, *quota.ScopeParameters) bool); ok { r0 = rf(ctx, target, scopeParams) } else { r0 = ret.Get(0).(bool) } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string, *quota.ScopeParameters) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, quota.TargetSrv, *quota.ScopeParameters) error); ok { r1 = rf(ctx, target, scopeParams) } else { r1 = ret.Error(1) @@ -51,16 +49,16 @@ type MockQuotaChecker_CheckQuotaReached_Call struct { } // CheckQuotaReached is a helper method to define mock.On call -// - ctx context.Context -// - target string -// - scopeParams *quota.ScopeParameters +// - ctx context.Context +// - target quota.TargetSrv +// - scopeParams *quota.ScopeParameters func (_e *MockQuotaChecker_Expecter) CheckQuotaReached(ctx interface{}, target interface{}, scopeParams interface{}) *MockQuotaChecker_CheckQuotaReached_Call { return &MockQuotaChecker_CheckQuotaReached_Call{Call: _e.mock.On("CheckQuotaReached", ctx, target, scopeParams)} } -func (_c *MockQuotaChecker_CheckQuotaReached_Call) Run(run func(ctx context.Context, target string, scopeParams *quota.ScopeParameters)) *MockQuotaChecker_CheckQuotaReached_Call { +func (_c *MockQuotaChecker_CheckQuotaReached_Call) Run(run func(ctx context.Context, target quota.TargetSrv, scopeParams *quota.ScopeParameters)) *MockQuotaChecker_CheckQuotaReached_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(*quota.ScopeParameters)) + run(args[0].(context.Context), args[1].(quota.TargetSrv), args[2].(*quota.ScopeParameters)) }) return _c } @@ -70,8 +68,13 @@ func (_c *MockQuotaChecker_CheckQuotaReached_Call) Return(_a0 bool, _a1 error) * return _c } -// NewMockQuotaChecker creates a new instance of MockQuotaChecker. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockQuotaChecker(t testing.TB) *MockQuotaChecker { +type mockConstructorTestingTNewMockQuotaChecker interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockQuotaChecker creates a new instance of MockQuotaChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockQuotaChecker(t mockConstructorTestingTNewMockQuotaChecker) *MockQuotaChecker { mock := &MockQuotaChecker{} mock.Mock.Test(t) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 1cb125de37c..581d09594fe 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -282,6 +283,29 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR }) } +// Count returns either the number of the alert rules under a specific org (if orgID is not zero) +// or the number of all the alert rules +func (st DBstore) Count(ctx context.Context, orgID int64) (int64, error) { + type result struct { + Count int64 + } + + r := result{} + err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) as count from alert_rule" + args := make([]interface{}, 0) + if orgID != 0 { + rawSQL += " WHERE org_id=?" + args = append(args, orgID) + } + if _, err := sess.SQL(rawSQL, args...).Get(&r); err != nil { + return err + } + return nil + }) + return r.Count, err +} + func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) { var interval int64 = 0 return interval, st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 3775f4a28d3..f970ed86da8 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -339,3 +339,7 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org } return result, nil } + +func (f *RuleStore) Count(ctx context.Context, orgID int64) (int64, error) { + return 0, nil +} diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 287dad05e3b..b0db8636df4 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -33,6 +33,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -78,7 +79,9 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, m := metrics.NewNGAlert(prometheus.NewRegistry()) sqlStore := db.InitTestDB(tb) secretsService := secretsManager.SetupTestService(tb, database.ProvideSecretsStore(sqlStore)) - dashboardStore := databasestore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := databasestore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(tb, err) ac := acmock.New() features := featuremgmt.WithFeatures() @@ -95,7 +98,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, folderService := folderimpl.ProvideService(ac, bus, cfg, dashboardService, dashboardStore, nil, features, folderPermissions, nil) ng, err := ngalert.ProvideService( - cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil, + cfg, &FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil), secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, annotationstest.NewFakeAnnotationsRepo(), ) require.NoError(tb, err) diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index 4dc0e2a0a2b..d6956ce2384 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -204,3 +204,9 @@ func (o ByOrgName) Less(i, j int) bool { return o[i].Name < o[j].Name } + +const ( + QuotaTargetSrv string = "org" + OrgQuotaTarget string = "org" + OrgUserQuotaTarget string = "org_user" +) diff --git a/pkg/services/org/orgimpl/org.go b/pkg/services/org/orgimpl/org.go index ca5539eb9a5..ed6c3bc506a 100644 --- a/pkg/services/org/orgimpl/org.go +++ b/pkg/services/org/orgimpl/org.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -18,9 +19,9 @@ type Service struct { log log.Logger } -func ProvideService(db db.DB, cfg *setting.Cfg) org.Service { +func ProvideService(db db.DB, cfg *setting.Cfg, quotaService quota.Service) (org.Service, error) { log := log.New("org service") - return &Service{ + s := &Service{ store: &sqlStore{ db: db, dialect: db.GetDialect(), @@ -30,6 +31,24 @@ func ProvideService(db db.DB, cfg *setting.Cfg) org.Service { cfg: cfg, log: log, } + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: quota.TargetSrv(org.QuotaTargetSrv), + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return s, nil + } + return s, nil +} + +func (s *Service) Usage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return s.store.Count(ctx, scopeParams) } func (s *Service) GetIDForNewUser(ctx context.Context, cmd org.GetOrgIDForNewUserCommand) (int64, error) { @@ -179,3 +198,31 @@ func (s *Service) GetOrgUsers(ctx context.Context, query *org.GetOrgUsersQuery) func (s *Service) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) { return s.store.SearchOrgUsers(ctx, query) } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope) + if err != nil { + return limits, err + } + orgQuotaTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope) + if err != nil { + return limits, err + } + userTag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.Org) + // users per org + limits.Set(orgQuotaTag, cfg.Quota.Org.User) + // orgs per user + limits.Set(userTag, cfg.Quota.User.Org) + return limits, nil +} diff --git a/pkg/services/org/orgimpl/org_test.go b/pkg/services/org/orgimpl/org_test.go index 9d9b48c862c..410bbf5a255 100644 --- a/pkg/services/org/orgimpl/org_test.go +++ b/pkg/services/org/orgimpl/org_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -135,3 +136,7 @@ func (f *FakeOrgStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgU func (f *FakeOrgStore) RemoveOrgUser(ctx context.Context, cmd *org.RemoveOrgUserCommand) error { return f.ExpectedError } + +func (f *FakeOrgStore) Count(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { + return nil, nil +} diff --git a/pkg/services/org/orgimpl/store.go b/pkg/services/org/orgimpl/store.go index afc900223b5..09936a3195d 100644 --- a/pkg/services/org/orgimpl/store.go +++ b/pkg/services/org/orgimpl/store.go @@ -14,6 +14,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -42,6 +44,8 @@ type store interface { GetByName(context.Context, *org.GetOrgByNameQuery) (*org.Org, error) SearchOrgUsers(context.Context, *org.SearchOrgUsersQuery) (*org.SearchOrgUsersQueryResult, error) RemoveOrgUser(context.Context, *org.RemoveOrgUserCommand) error + + Count(context.Context, *quota.ScopeParameters) (*quota.Map, error) } type sqlStore struct { @@ -395,6 +399,72 @@ func (ss *sqlStore) AddOrgUser(ctx context.Context, cmd *org.AddOrgUserCommand) }) } +func (ss *sqlStore) Count(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + type result struct { + Count int64 + } + + r := result{} + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := "SELECT COUNT(*) as count from org" + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + + if scopeParams.OrgID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM (SELECT user_id FROM org_user WHERE org_id=? AND user_id IN (SELECT id AS user_id FROM %s WHERE is_service_account=%s)) as subq", + ss.db.GetDialect().Quote("user"), + ss.db.GetDialect().BooleanStr(false), + ) + if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + } + + if scopeParams.UserID != 0 { + if err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + // should we exclude service accounts? + rawSQL := "SELECT COUNT(*) AS count FROM org_user WHERE user_id=?" + if _, err := sess.SQL(rawSQL, scopeParams.UserID).Get(&r); err != nil { + return err + } + return nil + }); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope) + if err != nil { + return u, err + } + u.Set(tag, r.Count) + } + } + + return u, nil +} + func setUsingOrgInTransaction(sess *db.Session, userID int64, orgID int64) error { user := user.User{ ID: userID, diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index c5aa5caa787..4a1eb4ff142 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -28,6 +28,7 @@ import ( publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -300,7 +301,8 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) } // create dashboard - dashboardStoreService := dashboardStore.ProvideDashboardStore(db, db.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, db.Cfg)) + dashboardStoreService, err := dashboardStore.ProvideDashboardStore(db, db.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db, db.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) dashboard, err := dashboardStoreService.SaveDashboard(context.Background(), saveDashboardCmd) require.NoError(t, err) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 6c66764b304..b217e324a6c 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -35,7 +36,9 @@ func TestIntegrationListPublicDashboard(t *testing.T) { t.Skip("skipping integration test") } sqlStore, cfg := db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) publicdashboardStore := ProvideStore(sqlStore) var orgId int64 = 1 @@ -78,7 +81,10 @@ func TestIntegrationFindDashboard(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -105,7 +111,10 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -175,7 +184,10 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -237,7 +249,10 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -299,10 +314,12 @@ func TestIntegrationFindByAccessToken(t *testing.T) { var dashboardStore *dashboardsDB.DashboardStore var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -369,7 +386,10 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) + dashboardStore = store publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, true) @@ -436,10 +456,13 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var anotherSavedDashboard *models.Dashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, 0, true) @@ -529,10 +552,13 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { var dashboardStore *dashboardsDB.DashboardStore var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotaService) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } @@ -599,10 +625,12 @@ func TestIntegrationDelete(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var savedPublicDashboard *PublicDashboard + var err error setup := func() { sqlStore, cfg = db.InitTestDBwithCfg(t) - dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg)) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.Uid, savedDashboard.OrgId, true) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index b64f1cb9299..81885ddcb65 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/setting" @@ -355,7 +356,8 @@ const ( func TestGetQueryDataResponse(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) service := &PublicDashboardServiceImpl{ @@ -738,7 +740,8 @@ func TestGetAnnotations(t *testing.T) { func TestGetMetricRequest(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) publicDashboard := &PublicDashboard{ @@ -811,7 +814,8 @@ func TestGetUniqueDashboardDatasourceUids(t *testing.T) { func TestBuildMetricRequest(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -1022,7 +1026,8 @@ func TestBuildMetricRequest(t *testing.T) { func TestBuildAnonymousUser(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) // publicdashboardStore := database.ProvideStore(sqlStore) // service := &PublicDashboardServiceImpl{ diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index caafbad220e..fcf700b36c4 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/database" "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -125,7 +126,9 @@ func TestGetPublicDashboard(t *testing.T) { func TestCreatePublicDashboard(t *testing.T) { t.Run("Create public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -147,7 +150,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err := service.Create(context.Background(), SignedInUser, dto) + _, err = service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -171,7 +174,9 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Validate pubdash has default time setting value", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -191,7 +196,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err := service.Create(context.Background(), SignedInUser, dto) + _, err = service.Create(context.Background(), SignedInUser, dto) require.NoError(t, err) pubdash, err := service.FindByDashboardUid(context.Background(), dashboard.OrgId, dashboard.Uid) @@ -201,7 +206,9 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Validate pubdash whose dashboard has template variables returns error", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) templateVars := make([]map[string]interface{}, 1) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, templateVars, nil) @@ -222,7 +229,7 @@ func TestCreatePublicDashboard(t *testing.T) { }, } - _, err := service.Create(context.Background(), SignedInUser, dto) + _, err = service.Create(context.Background(), SignedInUser, dto) require.Error(t, err) }) @@ -265,7 +272,8 @@ func TestCreatePublicDashboard(t *testing.T) { t.Run("Returns error if public dashboard exists", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -316,7 +324,9 @@ func TestCreatePublicDashboard(t *testing.T) { func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating public dashboard", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) @@ -378,7 +388,9 @@ func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating set empty time settings", func(t *testing.T) { sqlStore := db.InitTestDB(t) - dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + quotaService := quotatest.New(false, nil) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true, []map[string]interface{}{}, nil) diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index 32a17fcc787..af1d87c9263 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -23,6 +23,7 @@ import ( fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" dsSvc "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -389,7 +390,9 @@ func setup(t *testing.T) *testContext { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) ss := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) ssvc := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - ds := dsSvc.ProvideService(nil, ssvc, ss, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + ds, err := dsSvc.ProvideService(nil, ssvc, ss, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) fakeDatasourceService := &fakeDatasources.FakeDataSourceService{ DataSources: nil, SimulatePluginFailure: false, diff --git a/pkg/services/quota/context.go b/pkg/services/quota/context.go new file mode 100644 index 00000000000..2342d53d476 --- /dev/null +++ b/pkg/services/quota/context.go @@ -0,0 +1,42 @@ +package quota + +import ( + "context" + "sync" +) + +type Context struct { + context.Context + TargetToSrv *TargetToSrv +} + +func FromContext(ctx context.Context, targetToSrv *TargetToSrv) Context { + if targetToSrv == nil { + targetToSrv = NewTargetToSrv() + } + return Context{Context: ctx, TargetToSrv: targetToSrv} +} + +type TargetToSrv struct { + mutex sync.RWMutex + m map[Target]TargetSrv +} + +func NewTargetToSrv() *TargetToSrv { + return &TargetToSrv{m: make(map[Target]TargetSrv)} +} + +func (m *TargetToSrv) Get(target Target) (TargetSrv, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + srv, ok := m.m[target] + return srv, ok +} + +func (m *TargetToSrv) Set(target Target, srv TargetSrv) { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.m[target] = srv +} diff --git a/pkg/services/quota/model.go b/pkg/services/quota/model.go index d0e69700f68..091c60c4f36 100644 --- a/pkg/services/quota/model.go +++ b/pkg/services/quota/model.go @@ -1,10 +1,216 @@ package quota -import "errors" +import ( + "strings" + "sync" + "time" -var ErrInvalidQuotaTarget = errors.New("invalid quota target") + "github.com/grafana/grafana/pkg/util/errutil" +) + +var ErrBadRequest = errutil.NewBase(errutil.StatusBadRequest, "quota.bad-request") +var ErrInvalidTargetSrv = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-target") +var ErrInvalidScope = errutil.NewBase(errutil.StatusBadRequest, "quota.invalid-scope") +var ErrInvalidTarget = errutil.NewBase(errutil.StatusInternal, "quota.invalid-target-table") +var ErrTargetSrvConflict = errutil.NewBase(errutil.StatusBadRequest, "quota.target-srv-conflict") +var ErrDisabled = errutil.NewBase(errutil.StatusForbidden, "quota.disabled", errutil.WithPublicMessage("Quotas not enabled")) +var ErrInvalidTagFormat = errutil.NewBase(errutil.StatusInternal, "quota.invalid-invalid-tag-format") type ScopeParameters struct { OrgID int64 UserID int64 } + +type Scope string + +const ( + GlobalScope Scope = "global" + OrgScope Scope = "org" + UserScope Scope = "user" +) + +func (s Scope) Validate() error { + switch s { + case GlobalScope, OrgScope, UserScope: + return nil + default: + return ErrInvalidScope.Errorf("bad scope: %s", s) + } +} + +type TargetSrv string + +type Target string + +const delimiter = ":" + +// Tag is a string with the format :: +type Tag string + +func NewTag(srv TargetSrv, t Target, scope Scope) (Tag, error) { + if err := scope.Validate(); err != nil { + return "", err + } + + tag := Tag(strings.Join([]string{string(srv), string(t), string(scope)}, delimiter)) + return tag, nil +} + +func (t Tag) split() ([]string, error) { + parts := strings.SplitN(string(t), delimiter, -1) + if len(parts) != 3 { + return nil, ErrInvalidTagFormat.Errorf("tag format should be ^(?\\w):(?\\w):(?\\w)$") + } + + return parts, nil +} + +func (t Tag) GetSrv() (TargetSrv, error) { + parts, err := t.split() + if err != nil { + return "", err + } + return TargetSrv(parts[0]), nil +} + +func (t Tag) GetTarget() (Target, error) { + parts, err := t.split() + if err != nil { + return "", err + } + return Target(parts[1]), nil +} + +func (t Tag) GetScope() (Scope, error) { + parts, err := t.split() + if err != nil { + return "", err + } + return Scope(parts[2]), nil +} + +type Item struct { + Tag Tag + Value int64 +} + +type Map struct { + mutex sync.RWMutex + m map[Tag]int64 +} + +func (m *Map) Set(tag Tag, limit int64) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if len(m.m) == 0 { + m.m = make(map[Tag]int64, 0) + } + m.m[tag] = limit +} + +func (m *Map) Get(tag Tag) (int64, bool) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + limit, ok := m.m[tag] + return limit, ok +} + +func (m *Map) Merge(l2 *Map) { + l2.mutex.RLock() + defer l2.mutex.RUnlock() + + for k, v := range l2.m { + // TODO check for conflicts? + m.Set(k, v) + } +} + +func (m *Map) Iter() <-chan Item { + m.mutex.RLock() + defer m.mutex.RUnlock() + + ch := make(chan Item) + go func() { + defer close(ch) + for t, v := range m.m { + ch <- Item{Tag: t, Value: v} + } + }() + + return ch +} + +func (m *Map) Scopes() (map[Scope]struct{}, error) { + res := make(map[Scope]struct{}) + for item := range m.Iter() { + scope, err := item.Tag.GetScope() + if err != nil { + return nil, err + } + res[scope] = struct{}{} + } + return res, nil +} + +func (m *Map) Services() (map[TargetSrv]struct{}, error) { + res := make(map[TargetSrv]struct{}) + for item := range m.Iter() { + srv, err := item.Tag.GetSrv() + if err != nil { + return nil, err + } + res[srv] = struct{}{} + } + return res, nil +} + +func (m *Map) Targets() (map[Target]struct{}, error) { + res := make(map[Target]struct{}) + for item := range m.Iter() { + target, err := item.Tag.GetTarget() + if err != nil { + return nil, err + } + res[target] = struct{}{} + } + return res, nil +} + +type Quota struct { + Id int64 + OrgId int64 + UserId int64 + Target string + Limit int64 + Created time.Time + Updated time.Time +} + +type QuotaDTO struct { + OrgId int64 `json:"org_id,omitempty"` + UserId int64 `json:"user_id,omitempty"` + Target string `json:"target"` + Limit int64 `json:"limit"` + Used int64 `json:"used"` + Service string `json:"-"` + Scope string `json:"-"` +} + +func (dto QuotaDTO) Tag() (Tag, error) { + return NewTag(TargetSrv(dto.Service), Target(dto.Target), Scope(dto.Scope)) +} + +type UpdateQuotaCmd struct { + Target string `json:"target"` + Limit int64 `json:"limit"` + OrgID int64 `json:"-"` + UserID int64 `json:"-"` +} + +type NewUsageReporter struct { + TargetSrv TargetSrv + DefaultLimits *Map + Reporter UsageReporterFunc +} diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index 90cc46c878b..13045f41de2 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -7,7 +7,24 @@ import ( ) type Service interface { - QuotaReached(c *models.ReqContext, target string) (bool, error) - CheckQuotaReached(ctx context.Context, target string, scopeParams *ScopeParameters) (bool, error) - DeleteByUser(context.Context, int64) error + // GetQuotasByScope returns the quota for the specific scope (global, organization, user) + // If the scope is organization, the ID is expected to be the organisation ID. + // If the scope is user, the id is expected to be the user ID. + GetQuotasByScope(ctx context.Context, scope Scope, ID int64) ([]QuotaDTO, error) + // Update overrides the quota for a specific scope (global, organization, user). + // If the cmd.OrgID is set, then the organization quota are updated. + // If the cmd.UseID is set, then the user quota are updated. + Update(ctx context.Context, cmd *UpdateQuotaCmd) error + // QuotaReached is called by the quota middleware for applying quota enforcement to API handlers + QuotaReached(c *models.ReqContext, targetSrv TargetSrv) (bool, error) + // CheckQuotaReached checks if the quota limitations have been reached for a specific service + CheckQuotaReached(ctx context.Context, targetSrv TargetSrv, scopeParams *ScopeParameters) (bool, error) + // DeleteQuotaForUser deletes custom quota limitations for the user + DeleteQuotaForUser(ctx context.Context, userID int64) error + // DeleteByOrg(ctx context.Context, orgID int64) error + + // RegisterQuotaReporter registers a service UsageReporterFunc, targets and their default limits + RegisterQuotaReporter(e *NewUsageReporter) error } + +type UsageReporterFunc func(ctx context.Context, scopeParams *ScopeParameters) (*Map, error) diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index fb7f9fd6fc1..e435989fbfb 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -2,38 +2,81 @@ package quotaimpl import ( "context" + "fmt" + "sync" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "golang.org/x/sync/errgroup" ) -type Service struct { - store store - authTokenService models.ActiveTokenService - Cfg *setting.Cfg - SQLStore sqlstore.Store - Logger log.Logger +type serviceDisabled struct { } -func ProvideService(db db.DB, cfg *setting.Cfg, tokenService models.ActiveTokenService, ss *sqlstore.SQLStore) quota.Service { - return &Service{ - store: &sqlStore{db: db}, - Cfg: cfg, - authTokenService: tokenService, - SQLStore: ss, - Logger: log.New("quota_service"), +func (s *serviceDisabled) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { + return false, nil +} + +func (s *serviceDisabled) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { + return nil, quota.ErrDisabled +} + +func (s *serviceDisabled) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { + return quota.ErrDisabled +} + +func (s *serviceDisabled) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { + return false, nil +} + +func (s *serviceDisabled) DeleteQuotaForUser(ctx context.Context, userID int64) error { + return quota.ErrDisabled +} + +func (s *serviceDisabled) RegisterQuotaReporter(e *quota.NewUsageReporter) error { + return nil +} + +type service struct { + store store + Cfg *setting.Cfg + Logger log.Logger + + mutex sync.RWMutex + reporters map[quota.TargetSrv]quota.UsageReporterFunc + + defaultLimits *quota.Map + + targetToSrv *quota.TargetToSrv +} + +func ProvideService(db db.DB, cfg *setting.Cfg) quota.Service { + logger := log.New("quota_service") + s := service{ + store: &sqlStore{db: db, logger: logger}, + Cfg: cfg, + Logger: logger, + reporters: make(map[quota.TargetSrv]quota.UsageReporterFunc), + defaultLimits: "a.Map{}, + targetToSrv: quota.NewTargetToSrv(), } + + if s.IsDisabled() { + return &serviceDisabled{} + } + + return &s +} + +func (s *service) IsDisabled() bool { + return !s.Cfg.Quota.Enabled } // QuotaReached checks that quota is reached for a target. Runs CheckQuotaReached and take context and scope parameters from the request context -func (s *Service) QuotaReached(c *models.ReqContext, target string) (bool, error) { - if !s.Cfg.Quota.Enabled { - return false, nil - } +func (s *service) QuotaReached(c *models.ReqContext, targetSrv quota.TargetSrv) (bool, error) { // No request context means this is a background service, like LDAP Background Sync if c == nil { return false, nil @@ -46,91 +89,129 @@ func (s *Service) QuotaReached(c *models.ReqContext, target string) (bool, error UserID: c.UserID, } } - return s.CheckQuotaReached(c.Req.Context(), target, params) + return s.CheckQuotaReached(c.Req.Context(), targetSrv, params) +} + +func (s *service) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { + if err := scope.Validate(); err != nil { + return nil, err + } + + q := make([]quota.QuotaDTO, 0) + + scopeParams := quota.ScopeParameters{} + if scope == quota.OrgScope { + scopeParams.OrgID = id + } else if scope == quota.UserScope { + scopeParams.UserID = id + } + + c, err := s.getContext(ctx) + if err != nil { + return nil, err + } + customLimits, err := s.store.Get(c, &scopeParams) + if err != nil { + return nil, err + } + + u, err := s.getUsage(ctx, &scopeParams) + if err != nil { + return nil, err + } + + for item := range s.defaultLimits.Iter() { + limit := item.Value + + scp, err := item.Tag.GetScope() + if err != nil { + return nil, err + } + + if scp != scope { + continue + } + + if targetCustomLimit, ok := customLimits.Get(item.Tag); ok { + limit = targetCustomLimit + } + + target, err := item.Tag.GetTarget() + if err != nil { + return nil, err + } + + srv, err := item.Tag.GetSrv() + if err != nil { + return nil, err + } + + used, _ := u.Get(item.Tag) + q = append(q, quota.QuotaDTO{ + Target: string(target), + Limit: limit, + OrgId: scopeParams.OrgID, + UserId: scopeParams.UserID, + Used: used, + Service: string(srv), + Scope: string(scope), + }) + } + + return q, nil +} + +func (s *service) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { + targetFound := false + knownTargets, err := s.defaultLimits.Targets() + if err != nil { + return err + } + + for t := range knownTargets { + if t == quota.Target(cmd.Target) { + targetFound = true + } + } + if !targetFound { + return quota.ErrInvalidTarget.Errorf("unknown quota target: %s", cmd.Target) + } + + c, err := s.getContext(ctx) + if err != nil { + return err + } + return s.store.Update(c, cmd) } // CheckQuotaReached check that quota is reached for a target. If ScopeParameters are not defined, only global scope is checked -func (s *Service) CheckQuotaReached(ctx context.Context, target string, scopeParams *quota.ScopeParameters) (bool, error) { - if !s.Cfg.Quota.Enabled { - return false, nil - } - // get the list of scopes that this target is valid for. Org, User, Global - scopes, err := s.getQuotaScopes(target) +func (s *service) CheckQuotaReached(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (bool, error) { + targetSrvLimits, err := s.getOverridenLimits(ctx, targetSrv, scopeParams) if err != nil { return false, err } - for _, scope := range scopes { - s.Logger.Debug("Checking quota", "target", target, "scope", scope) - switch scope.Name { - case "global": - if scope.DefaultLimit < 0 { - continue - } - if scope.DefaultLimit == 0 { - return true, nil - } - if target == "session" { - usedSessions, err := s.authTokenService.ActiveTokenCount(ctx) - if err != nil { - return false, err - } + usageReporterFunc, ok := s.getReporter(targetSrv) + if !ok { + return false, quota.ErrInvalidTargetSrv + } + targetUsage, err := usageReporterFunc(ctx, scopeParams) + if err != nil { + return false, err + } - if usedSessions > scope.DefaultLimit { - s.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit) - return true, nil - } - continue + for t, limit := range targetSrvLimits { + switch { + case limit < 0: + continue + case limit == 0: + return true, nil + default: + u, ok := targetUsage.Get(t) + if !ok { + return false, fmt.Errorf("no usage for target:%s", t) } - query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target, UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled()} - // TODO : move GetGlobalQuotaByTarget to a global quota service - if err := s.SQLStore.GetGlobalQuotaByTarget(ctx, &query); err != nil { - return true, err - } - if query.Result.Used >= scope.DefaultLimit { - return true, nil - } - case "org": - if scopeParams == nil { - continue - } - query := models.GetOrgQuotaByTargetQuery{ - OrgId: scopeParams.OrgID, - Target: scope.Target, - Default: scope.DefaultLimit, - UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled(), - } - // TODO: move GetOrgQuotaByTarget from sqlstore to quota store - if err := s.SQLStore.GetOrgQuotaByTarget(ctx, &query); err != nil { - return true, err - } - if query.Result.Limit < 0 { - continue - } - if query.Result.Limit == 0 { - return true, nil - } - - if query.Result.Used >= query.Result.Limit { - return true, nil - } - case "user": - if scopeParams == nil || scopeParams.UserID == 0 { - continue - } - query := models.GetUserQuotaByTargetQuery{UserId: scopeParams.UserID, Target: scope.Target, Default: scope.DefaultLimit, UnifiedAlertingEnabled: s.Cfg.UnifiedAlerting.IsEnabled()} - // TODO: move GetUserQuotaByTarget from sqlstore to quota store - if err := s.SQLStore.GetUserQuotaByTarget(ctx, &query); err != nil { - return true, err - } - if query.Result.Limit < 0 { - continue - } - if query.Result.Limit == 0 { - return true, nil - } - - if query.Result.Used >= query.Result.Limit { + if u >= limit { return true, nil } } @@ -138,68 +219,127 @@ func (s *Service) CheckQuotaReached(ctx context.Context, target string, scopePar return false, nil } -func (s *Service) getQuotaScopes(target string) ([]models.QuotaScope, error) { - scopes := make([]models.QuotaScope, 0) - switch target { - case "user": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.User}, - models.QuotaScope{Name: "org", Target: "org_user", DefaultLimit: s.Cfg.Quota.Org.User}, - ) - return scopes, nil - case "org": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.Org}, - models.QuotaScope{Name: "user", Target: "org_user", DefaultLimit: s.Cfg.Quota.User.Org}, - ) - return scopes, nil - case "dashboard": - scopes = append(scopes, - models.QuotaScope{ - Name: "global", - Target: target, - DefaultLimit: s.Cfg.Quota.Global.Dashboard, - }, - models.QuotaScope{ - Name: "org", - Target: target, - DefaultLimit: s.Cfg.Quota.Org.Dashboard, - }, - ) - return scopes, nil - case "data_source": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.DataSource}, - models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.DataSource}, - ) - return scopes, nil - case "api_key": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.ApiKey}, - models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.ApiKey}, - ) - return scopes, nil - case "session": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.Session}, - ) - return scopes, nil - case "alert_rule": // target need to match the respective database name - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.AlertRule}, - models.QuotaScope{Name: "org", Target: target, DefaultLimit: s.Cfg.Quota.Org.AlertRule}, - ) - return scopes, nil - case "file": - scopes = append(scopes, - models.QuotaScope{Name: "global", Target: target, DefaultLimit: s.Cfg.Quota.Global.File}, - ) - return scopes, nil - default: - return scopes, quota.ErrInvalidQuotaTarget +func (s *service) DeleteQuotaForUser(ctx context.Context, userID int64) error { + c, err := s.getContext(ctx) + if err != nil { + return err } + return s.store.DeleteByUser(c, userID) } -func (s *Service) DeleteByUser(ctx context.Context, userID int64) error { - return s.store.DeleteByUser(ctx, userID) +func (s *service) RegisterQuotaReporter(e *quota.NewUsageReporter) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + _, ok := s.reporters[e.TargetSrv] + if ok { + return quota.ErrTargetSrvConflict.Errorf("target service: %s already exists", e.TargetSrv) + } + + s.reporters[e.TargetSrv] = e.Reporter + + for item := range e.DefaultLimits.Iter() { + target, err := item.Tag.GetTarget() + if err != nil { + return err + } + srv, err := item.Tag.GetSrv() + if err != nil { + return err + } + s.targetToSrv.Set(target, srv) + s.defaultLimits.Set(item.Tag, item.Value) + } + + return nil +} + +func (s *service) getReporter(target quota.TargetSrv) (quota.UsageReporterFunc, bool) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + r, ok := s.reporters[target] + return r, ok +} + +type reporter struct { + target quota.TargetSrv + reporterFunc quota.UsageReporterFunc +} + +func (s *service) getReporters() <-chan reporter { + ch := make(chan reporter) + go func() { + s.mutex.RLock() + defer func() { + s.mutex.RUnlock() + close(ch) + }() + for t, r := range s.reporters { + ch <- reporter{target: t, reporterFunc: r} + } + }() + + return ch +} + +func (s *service) getOverridenLimits(ctx context.Context, targetSrv quota.TargetSrv, scopeParams *quota.ScopeParameters) (map[quota.Tag]int64, error) { + targetSrvLimits := make(map[quota.Tag]int64) + + c, err := s.getContext(ctx) + if err != nil { + return nil, err + } + customLimits, err := s.store.Get(c, scopeParams) + if err != nil { + return targetSrvLimits, err + } + + for item := range s.defaultLimits.Iter() { + srv, err := item.Tag.GetSrv() + if err != nil { + return nil, err + } + + if srv != targetSrv { + continue + } + + defaultLimit := item.Value + + if customLimit, ok := customLimits.Get(item.Tag); ok { + targetSrvLimits[item.Tag] = customLimit + } else { + targetSrvLimits[item.Tag] = defaultLimit + } + } + + return targetSrvLimits, nil +} + +func (s *service) getUsage(ctx context.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + usage := "a.Map{} + g, ctx := errgroup.WithContext(ctx) + + for r := range s.getReporters() { + r := r + g.Go(func() error { + u, err := r.reporterFunc(ctx, scopeParams) + if err != nil { + return err + } + usage.Merge(u) + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + return usage, nil +} + +func (s *service) getContext(ctx context.Context) (quota.Context, error) { + return quota.FromContext(ctx, s.targetToSrv), nil } diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index c2cdfd5edda..17164adc785 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -3,26 +3,481 @@ package quotaimpl import ( "context" "testing" + "time" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/annotations/annotationstest" + "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" + "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/dashboards" + dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/datasources" + dsservice "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/ngalert" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" + ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + ngalerttests "github.com/grafana/grafana/pkg/services/ngalert/tests" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" + secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" + storesrv "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "github.com/xorcare/pointer" ) func TestQuotaService(t *testing.T) { - quotaStore := &FakeQuotaStore{} - quotaService := Service{ + quotaStore := "atest.FakeQuotaStore{} + quotaService := service{ store: quotaStore, } t.Run("delete quota", func(t *testing.T) { - err := quotaService.DeleteByUser(context.Background(), 1) + err := quotaService.DeleteQuotaForUser(context.Background(), 1) require.NoError(t, err) }) } -type FakeQuotaStore struct { - ExpectedError error +func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + sqlStore := sqlstore.InitTestDB(t) + sqlStore.Cfg.Quota = setting.QuotaSettings{ + Enabled: true, + + Org: setting.OrgQuota{ + User: 2, + Dashboard: 3, + DataSource: 4, + ApiKey: 5, + AlertRule: 6, + }, + User: setting.UserQuota{ + Org: 7, + }, + Global: setting.GlobalQuota{ + Org: 8, + User: 9, + Dashboard: 10, + DataSource: 11, + ApiKey: 12, + Session: 13, + AlertRule: 14, + File: 15, + }, + } + + b := bus.ProvideBus(tracing.InitializeTracerForTest()) + quotaService := ProvideService(sqlStore, sqlStore.Cfg) + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + userService, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService) + require.NoError(t, err) + setupEnv(t, sqlStore, b, quotaService) + + u, err := userService.Create(context.Background(), &user.CreateUserCommand{ + Name: "TestUser", + SkipOrgSetup: true, + }) + require.NoError(t, err) + + o, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{ + Name: "TestOrg", + UserID: u.ID, + }) + require.NoError(t, err) + + // fetch global default limit/usage + defaultGlobalLimits := make(map[quota.Tag]int64) + existingGlobalUsage := make(map[quota.Tag]int64) + scope := quota.GlobalScope + result, err := quotaService.GetQuotasByScope(context.Background(), scope, 0) + require.NoError(t, err) + for _, r := range result { + tag, err := r.Tag() + require.NoError(t, err) + defaultGlobalLimits[tag] = r.Limit + existingGlobalUsage[tag] = r.Used + } + tag, err := quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.Org, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.User, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.Dashboard, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.DataSource, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.ApiKey, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.Session, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.AlertRule, defaultGlobalLimits[tag]) + tag, err = quota.NewTag(storesrv.QuotaTargetSrv, storesrv.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Global.File, defaultGlobalLimits[tag]) + + // fetch default limit/usage for org + defaultOrgLimits := make(map[quota.Tag]int64) + existingOrgUsage := make(map[quota.Tag]int64) + scope = quota.OrgScope + result, err = quotaService.GetQuotasByScope(context.Background(), scope, o.ID) + require.NoError(t, err) + for _, r := range result { + tag, err := r.Tag() + require.NoError(t, err) + defaultOrgLimits[tag] = r.Limit + existingOrgUsage[tag] = r.Used + } + tag, err = quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.User, defaultOrgLimits[tag]) + tag, err = quota.NewTag(dashboards.QuotaTargetSrv, dashboards.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.Dashboard, defaultOrgLimits[tag]) + tag, err = quota.NewTag(datasources.QuotaTargetSrv, datasources.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.DataSource, defaultOrgLimits[tag]) + tag, err = quota.NewTag(apikey.QuotaTargetSrv, apikey.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.ApiKey, defaultOrgLimits[tag]) + tag, err = quota.NewTag(ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.Org.AlertRule, defaultOrgLimits[tag]) + + // fetch default limit/usage for user + defaultUserLimits := make(map[quota.Tag]int64) + existingUserUsage := make(map[quota.Tag]int64) + scope = quota.UserScope + result, err = quotaService.GetQuotasByScope(context.Background(), scope, u.ID) + require.NoError(t, err) + for _, r := range result { + tag, err := r.Tag() + require.NoError(t, err) + defaultUserLimits[tag] = r.Limit + existingUserUsage[tag] = r.Used + } + tag, err = quota.NewTag(quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), scope) + require.NoError(t, err) + require.Equal(t, sqlStore.Cfg.Quota.User.Org, defaultUserLimits[tag]) + + t.Run("Given saved org quota for users", func(t *testing.T) { + // update quota for the created org and limit users to 1 + var customOrgUserLimit int64 = 1 + orgCmd := quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: org.OrgUserQuotaTarget, + Limit: customOrgUserLimit, + } + err := quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved limit/usage for org users", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + + require.Equal(t, customOrgUserLimit, q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to get default org users limit/usage for unknown org", func(t *testing.T) { + unknownOrgID := -1 + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: int64(unknownOrgID)}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultOrgLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + t.Run("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { + // disable Grafana Alerting + cfg := *sqlStore.Cfg + cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{Enabled: pointer.Bool(false)} + + quotaSrv := ProvideService(sqlStore, &cfg) + q, err := getQuotaBySrvTargetScope(t, quotaSrv, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + + require.NoError(t, err) + require.Equal(t, int64(0), q.Limit) + }) + + t.Run("Should be able to quota list for org", func(t *testing.T) { + result, err := quotaService.GetQuotasByScope(context.Background(), quota.OrgScope, o.ID) + require.NoError(t, err) + require.Len(t, result, 5) + + require.NoError(t, err) + for _, res := range result { + tag, err := res.Tag() + require.NoError(t, err) + limit := defaultOrgLimits[tag] + used := existingOrgUsage[tag] + if res.Target == org.OrgUserQuotaTarget { + limit = customOrgUserLimit + used = 1 // one user in the created org + } + require.Equal(t, limit, res.Limit) + require.Equal(t, used, res.Used) + } + }) + }) + + t.Run("Given saved org quota for dashboards", func(t *testing.T) { + // update quota for the created org and limit dashboards to 1 + var customOrgDashboardLimit int64 = 1 + orgCmd := quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: string(dashboards.QuotaTarget), + Limit: customOrgDashboardLimit, + } + err := quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, customOrgDashboardLimit, q.Limit) + require.Equal(t, existingOrgUsage[tag], q.Used) + }) + }) + + t.Run("Given saved user quota for org", func(t *testing.T) { + // update quota for the created user and limit orgs to 1 + var customUserOrgsLimit int64 = 1 + userQuotaCmd := quota.UpdateQuotaCmd{ + UserID: u.ID, + Target: org.OrgUserQuotaTarget, + Limit: customUserOrgsLimit, + } + err := quotaService.Update(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + t.Run("Should be able to get saved limit/usage for user orgs", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) + require.NoError(t, err) + + require.Equal(t, customUserOrgsLimit, q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to get default user orgs limit/usage for unknown user", func(t *testing.T) { + var unknownUserID int64 = -1 + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: unknownUserID}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultUserLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + t.Run("Should be able to quota list for user", func(t *testing.T) { + result, err = quotaService.GetQuotasByScope(context.Background(), quota.UserScope, u.ID) + require.NoError(t, err) + require.Len(t, result, 1) + for _, res := range result { + tag, err := res.Tag() + require.NoError(t, err) + limit := defaultUserLimits[tag] + used := existingUserUsage[tag] + if res.Target == org.OrgUserQuotaTarget { + limit = customUserOrgsLimit // customized quota limit. + used = 1 // one user in the created org + } + require.Equal(t, limit, res.Limit) + require.Equal(t, used, res.Used) + } + }) + }) + + t.Run("Should be able to global user quota", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to global org quota", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgQuotaTarget), quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(1), q.Used) + }) + + t.Run("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + t.Run("Should be able to global dashboard quota", func(t *testing.T) { + q, err := getQuotaBySrvTargetScope(t, quotaService, dashboards.QuotaTargetSrv, dashboards.QuotaTarget, quota.GlobalScope, "a.ScopeParameters{}) + require.NoError(t, err) + + tag, err := q.Tag() + require.NoError(t, err) + require.Equal(t, defaultGlobalLimits[tag], q.Limit) + require.Equal(t, int64(0), q.Used) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + t.Run("Should org quota updating is successful even if it called multiple time", func(t *testing.T) { + // update quota for the created org and limit users to 1 + var customOrgUserLimit int64 = 1 + orgCmd := quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: org.OrgUserQuotaTarget, + Limit: customOrgUserLimit, + } + err := quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + query, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + require.Equal(t, customOrgUserLimit, query.Limit) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + customOrgUserLimit = 2 + orgCmd = quota.UpdateQuotaCmd{ + OrgID: o.ID, + Target: org.OrgUserQuotaTarget, + Limit: customOrgUserLimit, + } + err = quotaService.Update(context.Background(), &orgCmd) + require.NoError(t, err) + + query, err = getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) + require.NoError(t, err) + require.Equal(t, customOrgUserLimit, query.Limit) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + t.Run("Should user quota updating is successful even if it called multiple time", func(t *testing.T) { + // update quota for the created org and limit users to 1 + var customUserOrgLimit int64 = 1 + userQuotaCmd := quota.UpdateQuotaCmd{ + UserID: u.ID, + Target: org.OrgUserQuotaTarget, + Limit: customUserOrgLimit, + } + err := quotaService.Update(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + query, err := getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) + require.NoError(t, err) + require.Equal(t, customUserOrgLimit, query.Limit) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + customUserOrgLimit = 10 + userQuotaCmd = quota.UpdateQuotaCmd{ + UserID: u.ID, + Target: org.OrgUserQuotaTarget, + Limit: customUserOrgLimit, + } + err = quotaService.Update(context.Background(), &userQuotaCmd) + require.NoError(t, err) + + query, err = getQuotaBySrvTargetScope(t, quotaService, quota.TargetSrv(org.QuotaTargetSrv), quota.Target(org.OrgUserQuotaTarget), quota.UserScope, "a.ScopeParameters{UserID: u.ID}) + require.NoError(t, err) + require.Equal(t, customUserOrgLimit, query.Limit) + }) + + // TODO data_source, file } -func (f *FakeQuotaStore) DeleteByUser(ctx context.Context, userID int64) error { - return f.ExpectedError +func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quota.TargetSrv, target quota.Target, scope quota.Scope, scopeParams *quota.ScopeParameters) (quota.QuotaDTO, error) { + t.Helper() + + var id int64 = 0 + switch { + case scope == quota.OrgScope: + id = scopeParams.OrgID + case scope == quota.UserScope: + id = scopeParams.UserID + } + + result, err := quotaService.GetQuotasByScope(context.Background(), scope, id) + require.NoError(t, err) + for _, r := range result { + if r.Target != string(target) { + continue + } + + if r.Service != string(srv) { + continue + } + + if r.Scope != string(scope) { + continue + } + + require.Equal(t, r.OrgId, scopeParams.OrgID) + require.Equal(t, r.UserId, scopeParams.UserID) + return r, nil + } + return quota.QuotaDTO{}, err +} + +func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService quota.Service) { + _, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + _, err = auth.ProvideActiveAuthTokenService(sqlStore.Cfg, sqlStore, quotaService) + require.NoError(t, err) + _, err = dashboardStore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) + secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) + secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) + _, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New().WithDisabled(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) + m := metrics.NewNGAlert(prometheus.NewRegistry()) + _, err = ngalert.ProvideService( + sqlStore.Cfg, &ngalerttests.FakeFeatures{}, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotaService, + secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, annotationstest.NewFakeAnnotationsRepo(), + ) + require.NoError(t, err) + _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), sqlStore.Cfg, quotaService) + require.NoError(t, err) } diff --git a/pkg/services/quota/quotaimpl/store.go b/pkg/services/quota/quotaimpl/store.go index 6b3a32bdb91..d6111580f28 100644 --- a/pkg/services/quota/quotaimpl/store.go +++ b/pkg/services/quota/quotaimpl/store.go @@ -1,23 +1,130 @@ package quotaimpl import ( - "context" + "time" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" ) type store interface { - DeleteByUser(context.Context, int64) error + Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) + Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error + DeleteByUser(quota.Context, int64) error } type sqlStore struct { - db db.DB + db db.DB + logger log.Logger } -func (ss *sqlStore) DeleteByUser(ctx context.Context, userID int64) error { +func (ss *sqlStore) DeleteByUser(ctx quota.Context, userID int64) error { return ss.db.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM quota WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err }) } + +func (ss *sqlStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + limits := quota.Map{} + if scopeParams.OrgID != 0 { + orgLimits, err := ss.getOrgScopeQuota(ctx, scopeParams.OrgID) + if err != nil { + return nil, err + } + limits.Merge(orgLimits) + } + + if scopeParams.UserID != 0 { + userLimits, err := ss.getUserScopeQuota(ctx, scopeParams.UserID) + if err != nil { + return nil, err + } + limits.Merge(userLimits) + } + + return &limits, nil +} + +func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // Check if quota is already defined in the DB + quota := quota.Quota{ + Target: cmd.Target, + UserId: cmd.UserID, + OrgId: cmd.OrgID, + } + has, err := sess.Get("a) + if err != nil { + return err + } + quota.Updated = time.Now() + quota.Limit = cmd.Limit + if !has { + quota.Created = time.Now() + // No quota in the DB for this target, so create a new one. + if _, err := sess.Insert("a); err != nil { + return err + } + } else { + // update existing quota entry in the DB. + _, err := sess.ID(quota.Id).Update("a) + if err != nil { + return err + } + } + + return nil + }) +} + +func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.Map, error) { + r := quota.Map{} + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + quotas := make([]*quota.Quota, 0) + if err := sess.Table("quota").Where("user_id=? AND org_id=0", userID).Find("as); err != nil { + return err + } + + for _, q := range quotas { + srv, ok := ctx.TargetToSrv.Get(quota.Target(q.Target)) + if !ok { + ss.logger.Info("failed to get service for target", "target", q.Target) + } + tag, err := quota.NewTag(srv, quota.Target(q.Target), quota.UserScope) + if err != nil { + return err + } + r.Set(tag, q.Limit) + } + return nil + }) + return &r, err +} + +func (ss *sqlStore) getOrgScopeQuota(ctx quota.Context, OrgID int64) (*quota.Map, error) { + r := quota.Map{} + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + quotas := make([]*quota.Quota, 0) + if err := sess.Table("quota").Where("user_id=0 AND org_id=?", OrgID).Find("as); err != nil { + return err + } + + for _, q := range quotas { + srv, ok := ctx.TargetToSrv.Get(quota.Target(q.Target)) + if !ok { + ss.logger.Info("failed to get service for target", "target", q.Target) + } + tag, err := quota.NewTag(srv, quota.Target(q.Target), quota.OrgScope) + if err != nil { + return err + } + r.Set(tag, q.Limit) + } + return nil + }) + return &r, err +} diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index f9f7a184456..d332ab97851 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/quota" ) func TestIntegrationQuotaDataAccess(t *testing.T) { @@ -20,7 +21,8 @@ func TestIntegrationQuotaDataAccess(t *testing.T) { } t.Run("quota deleted", func(t *testing.T) { - err := quotaStore.DeleteByUser(context.Background(), 1) + ctx := quota.FromContext(context.Background(), "a.TargetToSrv{}) + err := quotaStore.DeleteByUser(ctx, 1) require.NoError(t, err) }) } diff --git a/pkg/services/quota/quotatest/fake.go b/pkg/services/quota/quotatest/fake.go index 00eae845789..d62267d9276 100644 --- a/pkg/services/quota/quotatest/fake.go +++ b/pkg/services/quota/quotatest/fake.go @@ -12,18 +12,46 @@ type FakeQuotaService struct { err error } -func NewQuotaServiceFake() *FakeQuotaService { - return &FakeQuotaService{} +func New(reached bool, err error) *FakeQuotaService { + return &FakeQuotaService{reached, err} } -func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target string) (bool, error) { +func (f *FakeQuotaService) GetQuotasByScope(ctx context.Context, scope quota.Scope, id int64) ([]quota.QuotaDTO, error) { + return []quota.QuotaDTO{}, nil +} + +func (f *FakeQuotaService) Update(ctx context.Context, cmd *quota.UpdateQuotaCmd) error { + return nil +} + +func (f *FakeQuotaService) QuotaReached(c *models.ReqContext, target quota.TargetSrv) (bool, error) { return f.reached, f.err } -func (f *FakeQuotaService) CheckQuotaReached(c context.Context, target string, params *quota.ScopeParameters) (bool, error) { +func (f *FakeQuotaService) CheckQuotaReached(c context.Context, target quota.TargetSrv, params *quota.ScopeParameters) (bool, error) { return f.reached, f.err } -func (f *FakeQuotaService) DeleteByUser(c context.Context, userID int64) error { +func (f *FakeQuotaService) DeleteQuotaForUser(c context.Context, userID int64) error { return f.err } + +func (f *FakeQuotaService) RegisterQuotaReporter(e *quota.NewUsageReporter) error { + return f.err +} + +type FakeQuotaStore struct { + ExpectedError error +} + +func (f *FakeQuotaStore) DeleteByUser(ctx quota.Context, userID int64) error { + return f.ExpectedError +} + +func (f *FakeQuotaStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) (*quota.Map, error) { + return nil, f.ExpectedError +} + +func (f *FakeQuotaStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { + return f.ExpectedError +} diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index a8e8ef8bcaa..ded9d12412f 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" dsservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -27,7 +29,9 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS features = featuremgmt.WithFeatures(featuremgmt.FlagDisableSecretsCompatibility, true) } secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) - dsService := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService()) + quotaService := quotatest.New(false, nil) + dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService(), quotaService) + require.NoError(t, err) migService := ProvideDataSourceMigrationService(dsService, kvStore, features) return migService } diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 0e87ed07c82..8abeeb43789 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" @@ -44,9 +45,12 @@ var ( func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) - orgService := orgimpl.ProvideService(store, setting.NewCfg()) + orgService, err := orgimpl.ProvideService(store, setting.NewCfg(), quotaService) + require.NoError(t, err) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, orgService) svcmock := tests.ServiceAccountMock{} @@ -57,7 +61,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { }() orgCmd := &models.CreateOrgCommand{Name: "Some Test Org"} - err := store.CreateOrg(context.Background(), orgCmd) + err = store.CreateOrg(context.Background(), orgCmd) require.Nil(t, err) type testCreateSATestCase struct { @@ -212,7 +216,9 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) { func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { store := db.InitTestDB(t) kvStore := kvstore.ProvideService(store) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -284,7 +290,9 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, sqlStore db.DB, saStore serviceaccounts.Store) (*web.Mux, *ServiceAccountsAPI) { cfg := setting.NewCfg() teamSvc := teamimpl.ProvideService(sqlStore, cfg) - userSvc := userimpl.ProvideService(sqlStore, nil, cfg, teamimpl.ProvideService(sqlStore, cfg), nil) + + userSvc, err := userimpl.ProvideService(sqlStore, nil, cfg, teamimpl.ProvideService(sqlStore, cfg), nil, quotatest.New(false, nil)) + require.NoError(t, err) saPermissionService, err := ossaccesscontrol.ProvideServiceAccountPermissions( cfg, routing.NewRouteRegister(), sqlStore, acmock, &licensing.OSSLicensingService{}, saStore, acmock, teamSvc, userSvc) require.NoError(t, err) @@ -316,7 +324,9 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -408,7 +418,9 @@ func newString(s string) *string { func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 9e9e91f4d98..90b234d24d2 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -23,6 +23,7 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" @@ -54,7 +55,9 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, func TestServiceAccountsAPI_CreateToken(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) svcmock := tests.ServiceAccountMock{} @@ -171,7 +174,9 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { func TestServiceAccountsAPI_DeleteToken(t *testing.T) { store := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(store, store.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(store) svcMock := &tests.ServiceAccountMock{} saStore := database.ProvideServiceAccountsStore(store, apiKeyService, kvStore, nil) diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index a6aba5f1367..be9011ed9bc 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -112,9 +113,12 @@ func TestStore_DeleteServiceAccount(t *testing.T) { func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreImpl) { t.Helper() db := db.InitTestDB(t) - apiKeyService := apikeyimpl.ProvideService(db, db.Cfg) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(db, db.Cfg, quotaService) + require.NoError(t, err) kvStore := kvstore.ProvideService(db) - orgService := orgimpl.ProvideService(db, setting.NewCfg()) + orgService, err := orgimpl.ProvideService(db, setting.NewCfg(), quotaService) + require.NoError(t, err) return db, ProvideServiceAccountsStore(db, apiKeyService, kvStore, orgService) } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 2671cc065e3..d8b5dea247b 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" @@ -70,8 +71,10 @@ func SetupApiKey(t *testing.T, sqlStore *sqlstore.SQLStore, testKey TestApiKey) addKeyCmd.Key = "secret" } - apiKeyService := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg) - err := apiKeyService.AddAPIKey(context.Background(), addKeyCmd) + quotaService := quotatest.New(false, nil) + apiKeyService, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + err = apiKeyService.AddAPIKey(context.Background(), addKeyCmd) require.NoError(t, err) if testKey.IsExpired { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index bf23c79e8a2..1b4757b2e93 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -98,34 +98,6 @@ func (m *SQLStoreMock) WithNewDbSession(ctx context.Context, callback sqlstore.D return m.ExpectedError } -func (m *SQLStoreMock) GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error { - return m.ExpectedError -} - -func (m *SQLStoreMock) GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error { - return m.ExpectedError -} - func (m *SQLStoreMock) WithTransactionalDbSession(ctx context.Context, callback sqlstore.DBTransactionFunc) error { return m.ExpectedError } diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go deleted file mode 100644 index a28dba881d7..00000000000 --- a/pkg/services/sqlstore/quota.go +++ /dev/null @@ -1,315 +0,0 @@ -package sqlstore - -import ( - "context" - "fmt" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" -) - -const ( - alertRuleTarget = "alert_rule" - dashboardTarget = "dashboard" - filesTarget = "file" -) - -type targetCount struct { - Count int64 -} - -func (ss *SQLStore) GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quota := models.Quota{ - Target: query.Target, - OrgId: query.OrgId, - } - has, err := sess.Get("a) - if err != nil { - return err - } else if !has { - quota.Limit = query.Default - } - - var used int64 - if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s WHERE org_id=?", - dialect.Quote(query.Target)) - - if query.Target == dashboardTarget { - rawSQL += fmt.Sprintf(" AND is_folder=%s", dialect.BooleanStr(false)) - } - // need to account for removing service accounts from the user table - if query.Target == "org_user" { - rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from (select user_id from %s where org_id=? AND user_id IN (SELECT id as user_id FROM %s WHERE is_service_account=%s)) as subq", - dialect.Quote(query.Target), - dialect.Quote("user"), - dialect.BooleanStr(false), - ) - } - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, query.OrgId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - - query.Result = &models.OrgQuotaDTO{ - Target: query.Target, - Limit: quota.Limit, - OrgId: query.OrgId, - Used: used, - } - - return nil - }) -} - -func (ss *SQLStore) GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quotas := make([]*models.Quota, 0) - if err := sess.Table("quota").Where("org_id=? AND user_id=0", query.OrgId).Find("as); err != nil { - return err - } - - defaultQuotas := setting.Quota.Org.ToMap() - - seenTargets := make(map[string]bool) - for _, q := range quotas { - seenTargets[q.Target] = true - } - - for t, v := range defaultQuotas { - if _, ok := seenTargets[t]; !ok { - quotas = append(quotas, &models.Quota{ - OrgId: query.OrgId, - Target: t, - Limit: v, - }) - } - } - - result := make([]*models.OrgQuotaDTO, len(quotas)) - for i, q := range quotas { - var used int64 - var rawSQL string - if q.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(q.Target)) - - // need to account for removing service accounts from the user table - if q.Target == "org_user" { - rawSQL = fmt.Sprintf("SELECT COUNT(*) as count from (select user_id from %s where org_id=? AND user_id IN (SELECT id as user_id FROM %s WHERE is_service_account=%s)) as subq", - dialect.Quote(q.Target), - dialect.Quote("user"), - dialect.BooleanStr(false), - ) - } - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, q.OrgId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - result[i] = &models.OrgQuotaDTO{ - Target: q.Target, - Limit: q.Limit, - OrgId: q.OrgId, - Used: used, - } - } - query.Result = result - return nil - }) -} - -func (ss *SQLStore) UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - // Check if quota is already defined in the DB - quota := models.Quota{ - Target: cmd.Target, - OrgId: cmd.OrgId, - } - has, err := sess.Get("a) - if err != nil { - return err - } - quota.Updated = time.Now() - quota.Limit = cmd.Limit - if !has { - quota.Created = time.Now() - // No quota in the DB for this target, so create a new one. - if _, err := sess.Insert("a); err != nil { - return err - } - } else { - // update existing quota entry in the DB. - _, err := sess.ID(quota.Id).Update("a) - if err != nil { - return err - } - } - - return nil - }) -} - -func (ss *SQLStore) GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quota := models.Quota{ - Target: query.Target, - UserId: query.UserId, - } - has, err := sess.Get("a) - if err != nil { - return err - } else if !has { - quota.Limit = query.Default - } - - var used int64 - if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(query.Target)) - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, query.UserId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - - query.Result = &models.UserQuotaDTO{ - Target: query.Target, - Limit: quota.Limit, - UserId: query.UserId, - Used: used, - } - - return nil - }) -} - -func (ss *SQLStore) GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - quotas := make([]*models.Quota, 0) - if err := sess.Table("quota").Where("user_id=? AND org_id=0", query.UserId).Find("as); err != nil { - return err - } - - defaultQuotas := setting.Quota.User.ToMap() - - seenTargets := make(map[string]bool) - for _, q := range quotas { - seenTargets[q.Target] = true - } - - for t, v := range defaultQuotas { - if _, ok := seenTargets[t]; !ok { - quotas = append(quotas, &models.Quota{ - UserId: query.UserId, - Target: t, - Limit: v, - }) - } - } - - result := make([]*models.UserQuotaDTO, len(quotas)) - for i, q := range quotas { - var used int64 - if q.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(q.Target)) - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL, q.UserId).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - result[i] = &models.UserQuotaDTO{ - Target: q.Target, - Limit: q.Limit, - UserId: q.UserId, - Used: used, - } - } - query.Result = result - return nil - }) -} - -func (ss *SQLStore) UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error { - return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { - // Check if quota is already defined in the DB - quota := models.Quota{ - Target: cmd.Target, - UserId: cmd.UserId, - } - has, err := sess.Get("a) - if err != nil { - return err - } - quota.Updated = time.Now() - quota.Limit = cmd.Limit - if !has { - quota.Created = time.Now() - // No quota in the DB for this target, so create a new one. - if _, err := sess.Insert("a); err != nil { - return err - } - } else { - // update existing quota entry in the DB. - _, err := sess.ID(quota.Id).Update("a) - if err != nil { - return err - } - } - - return nil - }) -} - -func (ss *SQLStore) GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error { - return ss.WithDbSession(ctx, func(sess *DBSession) error { - var used int64 - - if query.Target == filesTarget { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", - dialect.Quote("file")) - - notFolderCondition := fmt.Sprintf(" WHERE path NOT LIKE '%s'", "%/") - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL + notFolderCondition).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } else if query.Target != alertRuleTarget || query.UnifiedAlertingEnabled { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", - dialect.Quote(query.Target)) - - if query.Target == dashboardTarget { - rawSQL += fmt.Sprintf(" WHERE is_folder=%s", dialect.BooleanStr(false)) - } - // removing service accounts from count - if query.Target == dialect.Quote("user") { - rawSQL += fmt.Sprintf(" WHERE is_service_account=%s", dialect.BooleanStr(false)) - } - resp := make([]*targetCount, 0) - if err := sess.SQL(rawSQL).Find(&resp); err != nil { - return err - } - used = resp[0].Count - } - - query.Result = &models.GlobalQuotaDTO{ - Target: query.Target, - Limit: query.Default, - Used: used, - } - - return nil - }) -} diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go deleted file mode 100644 index e58b42adf8d..00000000000 --- a/pkg/services/sqlstore/quota_test.go +++ /dev/null @@ -1,301 +0,0 @@ -package sqlstore - -import ( - "context" - "testing" - "time" - - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" -) - -func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - sqlStore := InitTestDB(t) - userId := int64(1) - orgId := int64(0) - - setting.Quota = setting.QuotaSettings{ - Enabled: true, - Org: &setting.OrgQuota{ - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - AlertRule: 5, - }, - User: &setting.UserQuota{ - Org: 5, - }, - Global: &setting.GlobalQuota{ - Org: 5, - User: 5, - Dashboard: 5, - DataSource: 5, - ApiKey: 5, - Session: 5, - AlertRule: 5, - }, - } - createUserCmd := user.CreateUserCommand{ - Name: "TestUser", - OrgID: orgId, - SkipOrgSetup: true, - } - user, err := sqlStore.CreateUser(context.Background(), createUserCmd) - require.NoError(t, err) - // create a new org and add user_id 1 as admin. - // we will then have an org with 1 user. and a user - // with 1 org. - userCmd := models.CreateOrgCommand{ - Name: "TestOrg", - UserId: user.ID, - } - - err = sqlStore.CreateOrg(context.Background(), &userCmd) - require.NoError(t, err) - orgId = userCmd.Result.Id - - t.Run("Given saved org quota for users", func(t *testing.T) { - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: "org_user", - Limit: 10, - } - err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) - - t.Run("Should be able to get default quota by org id and target", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: 123, Target: "org_user", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(11), query.Result.Limit) - }) - - t.Run("Should be able to get used org quota when rows exist", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to get used org quota when no rows exist", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "org_user", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "alert", Default: 11} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to quota list for org", func(t *testing.T) { - query := models.GetOrgQuotasQuery{OrgId: orgId} - err = sqlStore.GetOrgQuotas(context.Background(), &query) - - require.NoError(t, err) - require.Len(t, query.Result, 5) - for _, res := range query.Result { - limit := int64(5) // default quota limit - used := int64(0) - if res.Target == "org_user" { - limit = 10 // customized quota limit. - used = 1 - } - require.Equal(t, limit, res.Limit) - require.Equal(t, used, res.Used) - } - }) - }) - - t.Run("Given saved org quota for dashboards", func(t *testing.T) { - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: dashboardTarget, - Limit: 10, - } - err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by org id and target", func(t *testing.T) { - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: dashboardTarget, Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - require.Equal(t, int64(0), query.Result.Used) - }) - }) - - t.Run("Given saved user quota for org", func(t *testing.T) { - userQuotaCmd := models.UpdateUserQuotaCmd{ - UserId: userId, - Target: "org_user", - Limit: 10, - } - err := sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - t.Run("Should be able to get saved quota by user id and target", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) - - t.Run("Should be able to get default quota by user id and target", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: 9, Target: "org_user", Default: 11} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(11), query.Result.Limit) - }) - - t.Run("Should be able to get used user quota when rows exist", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 11} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to get used user quota when no rows exist", func(t *testing.T) { - query := models.GetUserQuotaByTargetQuery{UserId: 2, Target: "org_user", Default: 11} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to quota list for user", func(t *testing.T) { - query := models.GetUserQuotasQuery{UserId: userId} - err = sqlStore.GetUserQuotas(context.Background(), &query) - - require.NoError(t, err) - require.Len(t, query.Result, 1) - require.Equal(t, int64(10), query.Result[0].Limit) - require.Equal(t, int64(1), query.Result[0].Used) - }) - }) - - t.Run("Should be able to global user quota", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: "user", Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to global org quota", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: "org", Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(1), query.Result.Used) - }) - - t.Run("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: "alert_rule", Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(0), query.Result.Used) - }) - - t.Run("Should be able to global dashboard quota", func(t *testing.T) { - query := models.GetGlobalQuotaByTargetQuery{Target: dashboardTarget, Default: 5} - err = sqlStore.GetGlobalQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - - require.Equal(t, int64(5), query.Result.Limit) - require.Equal(t, int64(0), query.Result.Used) - }) - - // related: https://github.com/grafana/grafana/issues/14342 - t.Run("Should org quota updating is successful even if it called multiple time", func(t *testing.T) { - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: "org_user", - Limit: 5, - } - err := sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - query := models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(5), query.Result.Limit) - - // XXX: resolution of `Updated` column is 1sec, so this makes delay - time.Sleep(1 * time.Second) - - orgCmd = models.UpdateOrgQuotaCmd{ - OrgId: orgId, - Target: "org_user", - Limit: 10, - } - err = sqlStore.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - - query = models.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} - err = sqlStore.GetOrgQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) - - // related: https://github.com/grafana/grafana/issues/14342 - t.Run("Should user quota updating is successful even if it called multiple time", func(t *testing.T) { - userQuotaCmd := models.UpdateUserQuotaCmd{ - UserId: userId, - Target: "org_user", - Limit: 5, - } - err := sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - query := models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(5), query.Result.Limit) - - // XXX: resolution of `Updated` column is 1sec, so this makes delay - time.Sleep(1 * time.Second) - - userQuotaCmd = models.UpdateUserQuotaCmd{ - UserId: userId, - Target: "org_user", - Limit: 10, - } - err = sqlStore.UpdateUserQuota(context.Background(), &userQuotaCmd) - require.NoError(t, err) - - query = models.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} - err = sqlStore.GetUserQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, int64(10), query.Result.Limit) - }) -} diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 463ad05b919..16ca3e885aa 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -23,13 +23,6 @@ type Store interface { GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error WithDbSession(ctx context.Context, callback DBTransactionFunc) error WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error - GetOrgQuotaByTarget(ctx context.Context, query *models.GetOrgQuotaByTargetQuery) error - GetOrgQuotas(ctx context.Context, query *models.GetOrgQuotasQuery) error - UpdateOrgQuota(ctx context.Context, cmd *models.UpdateOrgQuotaCmd) error - GetUserQuotaByTarget(ctx context.Context, query *models.GetUserQuotaByTargetQuery) error - GetUserQuotas(ctx context.Context, query *models.GetUserQuotasQuery) error - UpdateUserQuota(ctx context.Context, cmd *models.UpdateUserQuotaCmd) error - GetGlobalQuotaByTarget(ctx context.Context, query *models.GetGlobalQuotaByTargetQuery) error WithTransactionalDbSession(ctx context.Context, callback DBTransactionFunc) error InTransaction(ctx context.Context, fn func(ctx context.Context) error) error Migrate(bool) error diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index 61eb97c4239..cc3beab7d10 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -58,6 +59,11 @@ type CreateFolderCmd struct { Path string `json:"path"` } +const ( + QuotaTargetSrv quota.TargetSrv = "store" + QuotaTarget quota.Target = "file" +) + type StorageService interface { registry.BackgroundService @@ -97,7 +103,7 @@ func ProvideService( features featuremgmt.FeatureToggles, cfg *setting.Cfg, quotaService quota.Service, -) StorageService { +) (StorageService, error) { settings, err := LoadStorageConfig(cfg, features) if err != nil { grafanaStorageLogger.Warn("error loading storage config", "error", err) @@ -259,7 +265,37 @@ func ProvideService( s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg) s.quotaService = quotaService s.cfg = settings - return s + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return nil, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: QuotaTargetSrv, + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return nil, err + } + + return s, nil +} + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.File) + return limits, nil } func createSystemBrandingPathFilter() filestorage.PathFilter { @@ -329,6 +365,32 @@ func (s *standardStorageService) Read(ctx context.Context, user *user.SignedInUs return s.tree.GetFile(ctx, getOrgId(user), path) } +func (s *standardStorageService) Usage(ctx context.Context, ScopeParameters *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + + err := s.sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + type result struct { + Count int64 + } + r := result{} + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM file WHERE path NOT LIKE '%s'", "%/") + + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + + tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + if err != nil { + return err + } + u.Set(tag, r.Count) + + return nil + }) + + return u, err +} + type UploadRequest struct { Contents []byte Path string @@ -395,7 +457,7 @@ func (s *standardStorageService) Upload(ctx context.Context, user *user.SignedIn func (s *standardStorageService) checkFileQuota(ctx context.Context, path string) error { // assumes we are only uploading to the SQL database - TODO: refactor once we introduce object stores - quotaReached, err := s.quotaService.CheckQuotaReached(ctx, "file", nil) + quotaReached, err := s.quotaService.CheckQuotaReached(ctx, QuotaTargetSrv, nil) if err != nil { grafanaStorageLogger.Error("failed while checking upload quota", "path", path, "error", err) return ErrUploadInternalError diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 650c3dceefc..c74b744af16 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -118,7 +118,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } - store.quotaService = quotatest.NewQuotaServiceFake() + store.quotaService = quotatest.New(false, nil) return store, mockStorage, storageName } @@ -297,7 +297,7 @@ func TestContentRootWithNestedStorage(t *testing.T) { store.cfg = &GlobalStorageConfig{ AllowUnsanitizedSvgUpload: true, } - store.quotaService = quotatest.NewQuotaServiceFake() + store.quotaService = quotatest.New(false, nil) fileName := "file.jpg" tests := []struct { diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index b5d66f1b360..88951c1cc5a 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -357,3 +357,8 @@ type SearchUserFilter interface { } type FilterHandler func(params []string) (Filter, error) + +const ( + QuotaTargetSrv string = "user" + QuotaTarget string = "user" +) diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index c368be1389c..53deaf17c41 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -37,6 +38,8 @@ type store interface { BatchDisableUsers(context.Context, *user.BatchDisableUsersCommand) error Disable(context.Context, *user.DisableUserCommand) error Search(context.Context, *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) + + Count(ctx context.Context) (int64, error) } type sqlStore struct { @@ -461,6 +464,22 @@ func (ss *sqlStore) UpdatePermissions(ctx context.Context, userID int64, isAdmin }) } +func (ss *sqlStore) Count(ctx context.Context) (int64, error) { + type result struct { + Count int64 + } + + r := result{} + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s WHERE is_service_account=%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().BooleanStr(false)) + if _, err := sess.SQL(rawSQL).Get(&r); err != nil { + return err + } + return nil + }) + return r.Count, err +} + // validateOneAdminLeft validate that there is an admin user left func validateOneAdminLeft(ctx context.Context, sess *db.Session) error { count, err := sess.Where("is_admin=?", true).Count(&user.User{}) diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index 96dab700d94..f2250a5245d 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -32,15 +33,44 @@ func ProvideService( cfg *setting.Cfg, teamService team.Service, cacheService *localcache.CacheService, -) user.Service { + quotaService quota.Service, +) (user.Service, error) { store := ProvideStore(db, cfg) - return &Service{ + s := &Service{ store: &store, orgService: orgService, cfg: cfg, teamService: teamService, cacheService: cacheService, } + + defaultLimits, err := readQuotaConfig(cfg) + if err != nil { + return s, err + } + + if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ + TargetSrv: quota.TargetSrv(user.QuotaTargetSrv), + DefaultLimits: defaultLimits, + Reporter: s.Usage, + }); err != nil { + return s, err + } + return s, nil +} + +func (s *Service) Usage(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + if used, err := s.store.Count(ctx); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, used) + } + return u, nil } func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { @@ -304,3 +334,19 @@ func (s *Service) GetProfile(ctx context.Context, query *user.GetUserProfileQuer result, err := s.store.GetProfile(ctx, query) return result, err } + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.User) + return limits, nil +} diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index a371c74789d..aadd510e2ad 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -252,3 +252,7 @@ func (f *FakeUserStore) Disable(ctx context.Context, cmd *user.DisableUserComman func (f *FakeUserStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { return f.ExpectedSearchUserQueryResult, f.ExpectedError } + +func (f *FakeUserStore) Count(ctx context.Context) (int64, error) { + return 0, nil +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index d7ab99cb90c..09250fd5781 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -154,9 +154,6 @@ var ( LDAPAllowSignup bool LDAPActiveSyncEnabled bool - // Quota - Quota QuotaSettings - // Alerting AlertingEnabled *bool ExecuteAlerts bool @@ -423,12 +420,12 @@ type Cfg struct { LDAPSkipOrgRoleSync bool LDAPAllowSignup bool - Quota QuotaSettings - DefaultTheme string DefaultLocale string HomePage string + Quota QuotaSettings + AutoAssignOrg bool AutoAssignOrgId int AutoAssignOrgRole string @@ -1056,11 +1053,12 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.readAzureSettings() cfg.readSessionConfig() cfg.readSmtpSettings() - cfg.readQuotaSettings() if err := cfg.readAnnotationSettings(); err != nil { return err } + cfg.readQuotaSettings() + cfg.readExpressionsSettings() if err := cfg.readGrafanaEnvironmentMetrics(); err != nil { return err diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go index b3cd6d01115..053adb74662 100644 --- a/pkg/setting/setting_quota.go +++ b/pkg/setting/setting_quota.go @@ -1,9 +1,5 @@ package setting -import ( - "reflect" -) - type OrgQuota struct { User int64 `target:"org_user"` DataSource int64 `target:"data_source"` @@ -27,45 +23,17 @@ type GlobalQuota struct { File int64 `target:"file"` } -func (q *OrgQuota) ToMap() map[string]int64 { - return quotaToMap(*q) -} - -func (q *UserQuota) ToMap() map[string]int64 { - return quotaToMap(*q) -} - -func quotaToMap(q interface{}) map[string]int64 { - qMap := make(map[string]int64) - typ := reflect.TypeOf(q) - val := reflect.ValueOf(q) - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - name := field.Tag.Get("target") - if name == "" { - name = field.Name - } - if name == "-" { - continue - } - value := val.Field(i) - qMap[name] = value.Int() - } - return qMap -} - type QuotaSettings struct { Enabled bool - Org *OrgQuota - User *UserQuota - Global *GlobalQuota + Org OrgQuota + User UserQuota + Global GlobalQuota } func (cfg *Cfg) readQuotaSettings() { // set global defaults. quota := cfg.Raw.Section("quota") - Quota.Enabled = quota.Key("enabled").MustBool(false) + cfg.Quota.Enabled = quota.Key("enabled").MustBool(false) var alertOrgQuota int64 var alertGlobalQuota int64 @@ -74,7 +42,7 @@ func (cfg *Cfg) readQuotaSettings() { alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1) } // per ORG Limits - Quota.Org = &OrgQuota{ + cfg.Quota.Org = OrgQuota{ User: quota.Key("org_user").MustInt64(10), DataSource: quota.Key("org_data_source").MustInt64(10), Dashboard: quota.Key("org_dashboard").MustInt64(10), @@ -83,12 +51,12 @@ func (cfg *Cfg) readQuotaSettings() { } // per User limits - Quota.User = &UserQuota{ + cfg.Quota.User = UserQuota{ Org: quota.Key("user_org").MustInt64(10), } // Global Limits - Quota.Global = &GlobalQuota{ + cfg.Quota.Global = GlobalQuota{ User: quota.Key("global_user").MustInt64(-1), Org: quota.Key("global_org").MustInt64(-1), DataSource: quota.Key("global_data_source").MustInt64(-1), @@ -98,6 +66,4 @@ func (cfg *Cfg) readQuotaSettings() { File: quota.Key("global_file").MustInt64(-1), AlertRule: alertGlobalQuota, } - - cfg.Quota = Quota } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 0be39d6e06f..a4ca9ab479a 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -1877,6 +1876,8 @@ func TestQuota(t *testing.T) { // Create a user to make authenticated requests createUser(t, store, user.CreateUserCommand{ + // needs permission to update org quota + IsAdmin: true, DefaultOrgRole: string(org.RoleEditor), Password: "password", Login: "grafana", @@ -1917,30 +1918,10 @@ func TestQuota(t *testing.T) { // check quota limits t.Run("when quota limit exceed creating new rule should fail", func(t *testing.T) { // get existing org quota - query := models.GetOrgQuotaByTargetQuery{OrgId: 1, Target: "alert_rule"} - err = store.GetOrgQuotaByTarget(context.Background(), &query) - require.NoError(t, err) - used := query.Result.Used - limit := query.Result.Limit - - // set org quota limit to equal used - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: 1, - Target: "alert_rule", - Limit: used, - } - err := store.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) - + limit, used := apiClient.GetOrgQuotaLimits(t, 1) + apiClient.UpdateAlertRuleOrgQuota(t, 1, used) t.Cleanup(func() { - // reset org quota to original value - orgCmd := models.UpdateOrgQuotaCmd{ - OrgId: 1, - Target: "alert_rule", - Limit: limit, - } - err := store.UpdateOrgQuota(context.Background(), &orgCmd) - require.NoError(t, err) + apiClient.UpdateAlertRuleOrgQuota(t, 1, limit) }) // try to create an alert rule diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 0b2540a7628..f13443b5d2d 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -16,6 +16,7 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/util" ) @@ -202,6 +203,60 @@ func (a apiClient) CreateFolder(t *testing.T, uID string, title string) { a.ReloadCachedPermissions(t) } +func (a apiClient) GetOrgQuotaLimits(t *testing.T, orgID int64) (int64, int64) { + t.Helper() + + u := fmt.Sprintf("%s/api/orgs/%d/quotas", a.url, orgID) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + results := []quota.QuotaDTO{} + require.NoError(t, json.Unmarshal(b, &results)) + + var limit int64 = 0 + var used int64 = 0 + for _, q := range results { + if q.Target != string(ngmodels.QuotaTargetSrv) { + continue + } + limit = q.Limit + used = q.Used + } + return limit, used +} + +func (a apiClient) UpdateAlertRuleOrgQuota(t *testing.T, orgID int64, limit int64) { + t.Helper() + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode("a.UpdateQuotaCmd{ + Target: "alert_rule", + Limit: limit, + OrgID: orgID, + }) + require.NoError(t, err) + + u := fmt.Sprintf("%s/api/orgs/%d/quotas/alert_rule", a.url, orgID) + // nolint:gosec + client := &http.Client{} + req, err := http.NewRequest(http.MethodPut, u, &buf) + require.NoError(t, err) + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (int, string) { t.Helper() buf := bytes.Buffer{} diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go index 263fc24a828..beee540e81b 100644 --- a/pkg/tsdb/legacydata/service/service_test.go +++ b/pkg/tsdb/legacydata/service/service_test.go @@ -16,16 +16,14 @@ import ( datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" ) func TestHandleRequest(t *testing.T) { - cfg := &setting.Cfg{} - t.Run("Should invoke plugin manager QueryData when handling request for query", func(t *testing.T) { origOAuthIsOAuthPassThruEnabledFunc := oAuthIsOAuthPassThruEnabledFunc oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *datasources.DataSource) bool { @@ -46,7 +44,10 @@ func TestHandleRequest(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) datasourcePermissions := acmock.NewMockedPermissionsService() - dsService := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions) + quotaService := quotatest.New(false, nil) + dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, sqlStore.Cfg, featuremgmt.WithFeatures(), acmock.New(), datasourcePermissions, quotaService) + require.NoError(t, err) + s := ProvideService(client, nil, dsService) ds := &datasources.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()} From 59d2cf2ff790ccc55b82902b66b241c8d809dcb2 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon, 14 Nov 2022 19:13:33 +0000 Subject: [PATCH 231/926] Snapshots: Allow user with viewer permissions to delete own snapshots (#58572) Also allows deletion of snapshots whose original dashboard is in a folder which the viewer has explicit edit permissions for --- pkg/api/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 5eb40707bda..ef976fd10eb 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -684,5 +684,5 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) - r.Delete("/api/snapshots/:key", reqEditorRole, routing.Wrap(hs.DeleteDashboardSnapshot)) + r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot)) } From b9d8bcb59ba1725dcc58a0c0c7e3dc812d3a75b3 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Mon, 14 Nov 2022 17:00:28 -0400 Subject: [PATCH 232/926] Use relref resolved from nearest section (#58718) As image-rendering is a branch bundle, it is considered a section by Hugo and relrefs should be resolved from there even for child pages. The behavior that worked in `next` but not `latest` could be explained by the lenient but potentially ambiguous relref resolution algorithm Hugo uses. However, I have not determined the exact difference between the two sets of content that causes `next` to work but `latest` not to. Signed-off-by: Jack Baldry Signed-off-by: Jack Baldry --- .../setup-grafana/image-rendering/troubleshooting/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md index 02b1e310da0..2275d224309 100644 --- a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md +++ b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md @@ -30,9 +30,9 @@ filters = rendering:debug You can also enable more logs in image renderer service itself by: -- Increasing the [log level]({{< relref "../../image-rendering#log-level" >}}). -- Enabling [verbose logging]({{< relref "../../image-rendering#verbose-logging" >}}). -- [Capturing headless browser output]({{< relref "../../image-rendering#capture-browser-output" >}}). +- Increasing the [log level]({{< relref ".#log-level" >}}). +- Enabling [verbose logging]({{< relref "./#verbose-logging" >}}). +- [Capturing headless browser output]({{< relref "./#capture-browser-output" >}}). ## Missing libraries From d33939da55fd999e17adb53f26be0a58c7689a57 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 15 Nov 2022 01:35:50 +0100 Subject: [PATCH 233/926] DataSourceWithBackend: Add plugin id to the request headers (#58082) --- .betterer.results | 4 + .../src/utils/DataSourceWithBackend.test.ts | 25 +++++- .../src/utils/DataSourceWithBackend.ts | 77 +++++++++++++++++-- pkg/services/query/errors.go | 1 + pkg/services/query/query.go | 62 ++++++++++++++- pkg/services/query/query_test.go | 35 +++++++++ .../plugins/sql/datasource/SqlDatasource.ts | 2 + .../datasource/cloud-monitoring/datasource.ts | 1 + .../plugins/datasource/influxdb/datasource.ts | 1 + .../datasource/loki/datasource.test.ts | 4 +- .../datasource/prometheus/datasource.tsx | 1 + 11 files changed, 199 insertions(+), 14 deletions(-) diff --git a/.betterer.results b/.betterer.results index 090f63ed372..0e01318e04a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6203,6 +6203,10 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/plugins/datasource/loki/datasource.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/plugins/datasource/loki/datasource.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts index d414b934886..7c996cea7c5 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.test.ts @@ -32,7 +32,10 @@ jest.mock('../services', () => ({ getBackendSrv: () => backendSrv, getDataSourceSrv: () => { return { - getInstanceSettings: (ref?: DataSourceRef) => ({ type: ref?.type ?? '?', uid: ref?.uid ?? '?' }), + getInstanceSettings: (ref?: DataSourceRef) => ({ + type: ref?.type ?? '', + uid: ref?.uid ?? '', + }), }; }, })); @@ -43,6 +46,8 @@ describe('DataSourceWithBackend', () => { maxDataPoints: 10, intervalMs: 5000, targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }], + dashboardUID: 'dashA', + panelId: 123, } as DataQueryRequest); const args = mock.calls[0][0]; @@ -65,7 +70,7 @@ describe('DataSourceWithBackend', () => { Object { "datasource": Object { "type": "sample", - "uid": "?", + "uid": "", }, "datasourceId": undefined, "intervalMs": 5000, @@ -74,6 +79,12 @@ describe('DataSourceWithBackend', () => { }, ], }, + "headers": Object { + "X-Dashboard-Uid": "dashA", + "X-Datasource-Uid": "abc, ", + "X-Panel-Id": "123", + "X-Plugin-Id": "dummy, sample", + }, "hideFromInspector": false, "method": "POST", "requestId": undefined, @@ -88,6 +99,8 @@ describe('DataSourceWithBackend', () => { intervalMs: 5000, targets: [{ refId: 'A' }, { refId: 'B', datasource: { type: 'sample' } }], hideFromInspector: true, + dashboardUID: 'dashA', + panelId: 123, } as DataQueryRequest); const args = mock.calls[0][0]; @@ -110,7 +123,7 @@ describe('DataSourceWithBackend', () => { Object { "datasource": Object { "type": "sample", - "uid": "?", + "uid": "", }, "datasourceId": undefined, "intervalMs": 5000, @@ -119,6 +132,12 @@ describe('DataSourceWithBackend', () => { }, ], }, + "headers": Object { + "X-Dashboard-Uid": "dashA", + "X-Datasource-Uid": "abc, ", + "X-Panel-Id": "123", + "X-Plugin-Id": "dummy, sample", + }, "hideFromInspector": true, "method": "POST", "requestId": undefined, diff --git a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts index 3c8fe6800a2..9cdc73b7d72 100644 --- a/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts +++ b/packages/grafana-runtime/src/utils/DataSourceWithBackend.ts @@ -71,6 +71,14 @@ export enum HealthStatus { Error = 'ERROR', } +// Internal for now +enum PluginRequestHeaders { + PluginID = 'X-Plugin-Id', // can be used for routing + DatasourceUID = 'X-Datasource-Uid', // can be used for routing/ load balancing + DashboardUID = 'X-Dashboard-Uid', // mainly useful for debuging slow queries + PanelID = 'X-Panel-Id', // mainly useful for debuging slow queries +} + /** * Describes the details in the payload returned when checking the health of a data source * plugin. @@ -119,11 +127,15 @@ class DataSourceWithBackend< targets = targets.filter((q) => this.filterQuery!(q)); } + let hasExpr = false; + const pluginIDs = new Set(); + const dsUIDs = new Set(); const queries = targets.map((q) => { let datasource = this.getRef(); let datasourceId = this.id; if (isExpressionReference(q.datasource)) { + hasExpr = true; return { ...q, datasource: ExpressionDatasourceRef, @@ -140,7 +152,12 @@ class DataSourceWithBackend< datasource = ds.rawRef ?? getDataSourceRef(ds); datasourceId = ds.id; } - + if (datasource.type?.length) { + pluginIDs.add(datasource.type); + } + if (datasource.uid?.length) { + dsUIDs.add(datasource.uid); + } return { ...this.applyTemplateVariables(q, request.scopedVars), datasource, @@ -170,13 +187,28 @@ class DataSourceWithBackend< }); } + let url = '/api/ds/query'; + if (hasExpr) { + url += '?expression=true'; + } + + const headers: Record = {}; + headers[PluginRequestHeaders.PluginID] = Array.from(pluginIDs).join(', '); + headers[PluginRequestHeaders.DatasourceUID] = Array.from(dsUIDs).join(', '); + if (request.dashboardUID) { + headers[PluginRequestHeaders.DashboardUID] = request.dashboardUID; + } + if (request.panelId) { + headers[PluginRequestHeaders.PanelID] = `${request.panelId}`; + } return getBackendSrv() .fetch({ - url: '/api/ds/query', + url, method: 'POST', data: body, requestId, hideFromInspector, + headers, }) .pipe( switchMap((raw) => { @@ -193,6 +225,14 @@ class DataSourceWithBackend< ); } + /** Get request headers with plugin ID+UID set */ + protected getRequestHeaders(): Record { + const headers: Record = {}; + headers[PluginRequestHeaders.PluginID] = this.type; + headers[PluginRequestHeaders.DatasourceUID] = this.uid; + return headers; + } + /** * Apply template variables for explore */ @@ -221,23 +261,43 @@ class DataSourceWithBackend< /** * Make a GET request to the datasource resource path */ - async getResource( + async getResource( path: string, params?: BackendSrvRequest['params'], options?: Partial - ): Promise { - return getBackendSrv().get(`/api/datasources/${this.id}/resources/${path}`, params, options?.requestId, options); + ): Promise { + const headers = this.getRequestHeaders(); + const result = await lastValueFrom( + getBackendSrv().fetch({ + ...options, + method: 'GET', + headers: options?.headers ? { ...options.headers, ...headers } : headers, + params: params ?? options?.params, + url: `/api/datasources/${this.id}/resources/${path}`, + }) + ); + return result.data; } /** * Send a POST request to the datasource resource path */ - async postResource( + async postResource( path: string, data?: BackendSrvRequest['data'], options?: Partial - ): Promise { - return getBackendSrv().post(`/api/datasources/${this.id}/resources/${path}`, { ...data }, options); + ): Promise { + const headers = this.getRequestHeaders(); + const result = await lastValueFrom( + getBackendSrv().fetch({ + ...options, + method: 'GET', + headers: options?.headers ? { ...options.headers, ...headers } : headers, + data: data ?? { ...data }, + url: `/api/datasources/${this.id}/resources/${path}`, + }) + ); + return result.data; } /** @@ -249,6 +309,7 @@ class DataSourceWithBackend< method: 'GET', url: `/api/datasources/${this.id}/health`, showErrorAlert: false, + headers: this.getRequestHeaders(), }) ) .then((v: FetchResponse) => v.data as HealthCheckResult) diff --git a/pkg/services/query/errors.go b/pkg/services/query/errors.go index 6a3574467b4..f64d394757a 100644 --- a/pkg/services/query/errors.go +++ b/pkg/services/query/errors.go @@ -8,4 +8,5 @@ var ( ErrNoQueriesFound = errutil.NewBase(errutil.StatusBadRequest, "query.noQueries", errutil.WithPublicMessage("No queries found")).Errorf("no queries found") ErrInvalidDatasourceID = errutil.NewBase(errutil.StatusBadRequest, "query.invalidDatasourceId", errutil.WithPublicMessage("Query does not contain a valid data source identifier")).Errorf("invalid data source identifier") ErrMissingDataSourceInfo = errutil.NewBase(errutil.StatusBadRequest, "query.missingDataSourceInfo").MustTemplate("query missing datasource info: {{ .Public.RefId }}", errutil.WithPublic("Query {{ .Public.RefId }} is missing datasource information")) + ErrQueryParamMismatch = errutil.NewBase(errutil.StatusBadRequest, "query.headerMismatch", errutil.WithPublicMessage("The request headers point to a different plugin than is defined in the request body")).Errorf("plugin header/body mismatch") ) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 390e6d2c9f4..02803abf92b 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "time" "github.com/grafana/grafana/pkg/api/dtos" @@ -28,6 +29,13 @@ import ( "golang.org/x/sync/errgroup" ) +const ( + HeaderPluginID = "X-Plugin-Id" // can be used for routing + HeaderDatasourceUID = "X-Datasource-Uid" // can be used for routing/ load balancing + HeaderDashboardUID = "X-Dashboard-Uid" // mainly useful for debuging slow queries + HeaderPanelID = "X-Panel-Id" // mainly useful for debuging slow queries +) + func ProvideService( cfg *setting.Cfg, dataSourceCache datasources.CacheService, @@ -75,6 +83,7 @@ func (s *Service) QueryData(ctx context.Context, user *user.SignedInUser, skipCa if err != nil { return nil, err } + // If there are expressions, handle them and return if parsedReq.hasExpression { return s.handleExpressions(ctx, user, parsedReq) @@ -233,6 +242,7 @@ type parsedQuery struct { type parsedRequest struct { hasExpression bool parsedQueries map[string][]parsedQuery + dsTypes map[string]bool httpRequest *http.Request } @@ -244,6 +254,53 @@ func (pr parsedRequest) getFlattenedQueries() []parsedQuery { return queries } +func (pr parsedRequest) validateRequest() error { + if pr.httpRequest == nil { + return nil + } + + vals := splitHeaders(pr.httpRequest.Header.Values(HeaderDatasourceUID)) + count := len(vals) + if count > 0 { // header exists + if count != len(pr.parsedQueries) { + return ErrQueryParamMismatch + } + for _, t := range vals { + if pr.parsedQueries[t] == nil { + return ErrQueryParamMismatch + } + } + } + + vals = splitHeaders(pr.httpRequest.Header.Values(HeaderPluginID)) + count = len(vals) + if count > 0 { // header exists + if count != len(pr.dsTypes) { + return ErrQueryParamMismatch + } + for _, t := range vals { + if !pr.dsTypes[t] { + return ErrQueryParamMismatch + } + } + } + return nil +} + +func splitHeaders(headers []string) []string { + out := []string{} + for _, v := range headers { + if strings.Contains(v, ",") { + for _, sub := range strings.Split(v, ",") { + out = append(out, strings.TrimSpace(sub)) + } + } else { + out = append(out, v) + } + } + return out +} + // parseRequest parses a request into parsed queries grouped by datasource uid func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUser, skipCache bool, reqDTO dtos.MetricRequest) (*parsedRequest, error) { if len(reqDTO.Queries) == 0 { @@ -254,6 +311,7 @@ func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUse req := &parsedRequest{ hasExpression: false, parsedQueries: make(map[string][]parsedQuery), + dsTypes: make(map[string]bool), } // Parse the queries and store them by datasource @@ -270,6 +328,8 @@ func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUse datasourcesByUid[ds.Uid] = ds if expr.IsDataSource(ds.Uid) { req.hasExpression = true + } else { + req.dsTypes[ds.Type] = true } if _, ok := req.parsedQueries[ds.Uid]; !ok { @@ -304,7 +364,7 @@ func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUse req.httpRequest = reqDTO.HTTPRequest } - return req, nil + return req, req.validateRequest() } func (s *Service) getDataSourceFromQuery(ctx context.Context, user *user.SignedInUser, skipCache bool, query *simplejson.Json, history map[string]*datasources.DataSource) (*datasources.DataSource, error) { diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index af1d87c9263..f5d931c8784 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -1,6 +1,7 @@ package query import ( + "bytes" "context" "errors" "net/http" @@ -169,6 +170,40 @@ func TestParseMetricRequest(t *testing.T) { _, err = tc.queryService.handleExpressions(context.Background(), tc.signedInUser, parsedReq) assert.NoError(t, err) }) + + t.Run("Header validation", func(t *testing.T) { + mr := metricRequestWithQueries(t, `{ + "refId": "A", + "datasource": { + "uid": "gIEkMvIVz", + "type": "postgres" + } + }`, `{ + "refId": "B", + "datasource": { + "uid": "sEx6ZvSVk", + "type": "testdata" + } + }`) + httpreq, _ := http.NewRequest(http.MethodPost, "http://localhost/", bytes.NewReader([]byte{})) + httpreq.Header.Add("X-Datasource-Uid", "gIEkMvIVz") + mr.HTTPRequest = httpreq + _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) + require.Error(t, err) + + // With the second value it is OK + httpreq.Header.Add("X-Datasource-Uid", "sEx6ZvSVk") + mr.HTTPRequest = httpreq + _, err = tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) + require.NoError(t, err) + + // Single header with comma syntax + httpreq, _ = http.NewRequest(http.MethodPost, "http://localhost/", bytes.NewReader([]byte{})) + httpreq.Header.Set("X-Datasource-Uid", "gIEkMvIVz, sEx6ZvSVk") + mr.HTTPRequest = httpreq + _, err = tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) + require.NoError(t, err) + }) } func TestQueryDataMultipleSources(t *testing.T) { diff --git a/public/app/features/plugins/sql/datasource/SqlDatasource.ts b/public/app/features/plugins/sql/datasource/SqlDatasource.ts index 9a71dbe11bc..ab621f868d7 100644 --- a/public/app/features/plugins/sql/datasource/SqlDatasource.ts +++ b/public/app/features/plugins/sql/datasource/SqlDatasource.ts @@ -148,6 +148,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend({ url: '/api/ds/query', method: 'POST', + headers: this.getRequestHeaders(), data: { from: options?.range?.from.valueOf().toString() || range.from.valueOf().toString(), to: options?.range?.to.valueOf().toString() || range.to.valueOf().toString(), @@ -171,6 +172,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend({ url: '/api/ds/query', method: 'POST', + headers: this.getRequestHeaders(), data: { from: '5m', to: 'now', diff --git a/public/app/plugins/datasource/cloud-monitoring/datasource.ts b/public/app/plugins/datasource/cloud-monitoring/datasource.ts index 4a1f5f542d5..1ae6d23f7e4 100644 --- a/public/app/plugins/datasource/cloud-monitoring/datasource.ts +++ b/public/app/plugins/datasource/cloud-monitoring/datasource.ts @@ -110,6 +110,7 @@ export default class CloudMonitoringDatasource extends DataSourceWithBackend< return getBackendSrv().fetch({ url: '/api/ds/query', method: 'POST', + headers: this.getRequestHeaders(), data: { from: options.range.from.valueOf().toString(), to: options.range.to.valueOf().toString(), diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 83b90ab3746..49d55785947 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -409,6 +409,7 @@ export default class InfluxDatasource extends DataSourceWithBackend({ url: '/api/ds/query', method: 'POST', + headers: this.getRequestHeaders(), data: { from: options.range.from.valueOf().toString(), to: options.range.to.valueOf().toString(), diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index dc5cc8a57d0..0b9046ce348 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -882,7 +882,7 @@ describe('LokiDatasource', () => { }); it('keeps all labels when no labels are loaded', async () => { - ds.getResource = () => Promise.resolve({ data: [] }); + ds.getResource = () => Promise.resolve({ data: [] } as any); const queries = await ds.importFromAbstractQueries([ { refId: 'A', @@ -896,7 +896,7 @@ describe('LokiDatasource', () => { }); it('filters out non existing labels', async () => { - ds.getResource = () => Promise.resolve({ data: ['foo'] }); + ds.getResource = () => Promise.resolve({ data: ['foo'] } as any); const queries = await ds.importFromAbstractQueries([ { refId: 'A', diff --git a/public/app/plugins/datasource/prometheus/datasource.tsx b/public/app/plugins/datasource/prometheus/datasource.tsx index 0cd3b4819a4..e91df665913 100644 --- a/public/app/plugins/datasource/prometheus/datasource.tsx +++ b/public/app/plugins/datasource/prometheus/datasource.tsx @@ -773,6 +773,7 @@ export class PrometheusDatasource .fetch({ url: '/api/ds/query', method: 'POST', + headers: this.getRequestHeaders(), data: { from: (this.getPrometheusTime(options.range.from, false) * 1000).toString(), to: (this.getPrometheusTime(options.range.to, true) * 1000).toString(), From eff5450ff1b99e68b93c70314eb07cfdd7d8a27d Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Tue, 15 Nov 2022 07:25:13 +0000 Subject: [PATCH 234/926] Search: Revert "load dashboard performance improvements" (#58730) Revert "Search: load dashboard performance improvements (#57509)" This reverts commit 1df8a85a42d6559f08e341a62f330b265baee2b4. --- pkg/services/searchV2/index.go | 99 +++++----------------------------- 1 file changed, 14 insertions(+), 85 deletions(-) diff --git a/pkg/services/searchV2/index.go b/pkg/services/searchV2/index.go index 474836f9dca..7657bd9f115 100644 --- a/pkg/services/searchV2/index.go +++ b/pkg/services/searchV2/index.go @@ -846,101 +846,30 @@ func (l sqlDashboardLoader) loadAllDashboards(ctx context.Context, limit int, or dashboardQuerySpan.SetAttributes("dashboardUID", dashboardUID, attribute.Key("dashboardUID").String(dashboardUID)) dashboardQuerySpan.SetAttributes("lastID", lastID, attribute.Key("lastID").Int64(lastID)) - var slices [][]string + rows := make([]*dashboardQueryResult, 0) err := l.sql.WithDbSession(dashboardQueryCtx, func(sess *db.Session) error { - sql := "select id, uid, is_folder, folder_id, slug, data, created, updated from dashboard where org_id = ?" - sqlAndArgs := []interface{}{"", orgID} + sess.Table("dashboard"). + Where("org_id = ?", orgID) if lastID > 0 { - sql += " AND id > ?" - sqlAndArgs = append(sqlAndArgs, lastID) + sess.Where("id > ?", lastID) } if dashboardUID != "" { - sql += " AND uid = ?" - sqlAndArgs = append(sqlAndArgs, dashboardUID) + sess.Where("uid = ?", dashboardUID) } - sql += " order by id asc" - sql += " limit ?" - sqlAndArgs = append(sqlAndArgs, limit) + sess.Cols("id", "uid", "is_folder", "folder_id", "data", "slug", "created", "updated") - sqlAndArgs[0] = sql - output, err := sess.QuerySliceString(sqlAndArgs...) - slices = output - return err + sess.OrderBy("id ASC") + sess.Limit(limit) + + return sess.Find(&rows) }) - dashboardQuerySpan.SetAttributes("dashboardCount", len(slices), attribute.Key("dashboardCount").Int(len(slices))) - - if err != nil || slices == nil { - dashboardQuerySpan.End() - ch <- &dashboardsRes{ - dashboards: nil, - err: err, - } - break - } - - rows := make([]*dashboardQueryResult, len(slices)) - var parsingErr error - for i := range slices { - if len(slices[i]) < 8 { - parsingErr = fmt.Errorf("expected the dashboard row at index %d to contain 8 elements, has %d. lastID: %d", i, len(slices[i]), lastID) - break - } - - id, err := strconv.ParseInt(slices[i][0], 10, 64) - if err != nil { - parsingErr = err - break - } - uid := slices[i][1] - isFolder := false - if slices[i][2] == "1" { - isFolder = true - } - - folderID, err := strconv.ParseInt(slices[i][3], 10, 64) - if err != nil { - parsingErr = err - break - } - - // xorm/session_query.go::value2String() uses `time.RFC3339Nano` to format the time type - created, err := time.Parse(time.RFC3339Nano, slices[i][6]) - if err != nil { - parsingErr = err - break - } - updated, err := time.Parse(time.RFC3339Nano, slices[i][7]) - if err != nil { - parsingErr = err - break - } - - rows[i] = &dashboardQueryResult{ - Id: id, - Uid: uid, - IsFolder: isFolder, - FolderID: folderID, - Slug: slices[i][4], - Data: []byte(slices[i][5]), - Created: created, - Updated: updated, - } - } - dashboardQuerySpan.End() - if parsingErr != nil { - ch <- &dashboardsRes{ - dashboards: nil, - err: parsingErr, - } - break - } - if len(rows) < limit || dashboardUID != "" { + if err != nil || len(rows) < limit || dashboardUID != "" { ch <- &dashboardsRes{ dashboards: rows, err: err, @@ -1010,7 +939,7 @@ func (l sqlDashboardLoader) LoadDashboards(ctx context.Context, orgID int64, das for { res, ok := <-dashboardsChannel if res != nil && res.err != nil { - l.logger.Error("Error when loading dashboards", "error", res.err, "orgID", orgID, "dashboardUID", dashboardUID) + l.logger.Error("Error when loading dashboards", "error", err, "orgID", orgID, "dashboardUID", dashboardUID) break } @@ -1020,14 +949,14 @@ func (l sqlDashboardLoader) LoadDashboards(ctx context.Context, orgID int64, das rows := res.dashboards - readDashboardCtx, readDashboardSpan := l.tracer.Start(ctx, "sqlDashboardLoader readDashboard") + _, readDashboardSpan := l.tracer.Start(ctx, "sqlDashboardLoader readDashboard") readDashboardSpan.SetAttributes("orgID", orgID, attribute.Key("orgID").Int64(orgID)) readDashboardSpan.SetAttributes("dashboardCount", len(rows), attribute.Key("dashboardCount").Int(len(rows))) reader := kdash.NewStaticDashboardSummaryBuilder(lookup, false) for _, row := range rows { - summary, _, err := reader(readDashboardCtx, row.Uid, row.Data) + summary, _, err := reader(ctx, row.Uid, row.Data) if err != nil { l.logger.Warn("Error indexing dashboard data", "error", err, "dashboardId", row.Id, "dashboardSlug", row.Slug) // But append info anyway for now, since we possibly extracted useful information. From 16aa4376acf2d5eb559feeb514f1852cd3b48ff9 Mon Sep 17 00:00:00 2001 From: matt abrams <37156449+zuchka@users.noreply.github.com> Date: Tue, 15 Nov 2022 09:37:37 +0100 Subject: [PATCH 235/926] Transformations: Make Card Descriptions Clickable (#58717) replace Card Meta w Card Description --- .../components/TransformationsEditor/TransformationsEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index 40b8b297a43..166bce18c73 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -380,7 +380,7 @@ function TransformationCard({ transform, onClick }: TransformationCardProps) { onClick={onClick} > {transform.name} - {transform.description} + {transform.description} {transform.state && ( From 80e80221b9e4f02fb09957eb0a5c01f9dc01c83c Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 15 Nov 2022 00:49:39 -0800 Subject: [PATCH 236/926] Scenes: Grid layout (#56737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: First approach to scene grid layout * Flex layout * Grid layout rows * Allow passing custom props to scene object renderers * Allow nesting grid layouts * Re-layout nested grid's enclosing grids * Update public/app/features/scenes/components/layout/SceneGridLayout.tsx Co-authored-by: Torkel Ödegaard * Review comments * Got rid of flex & grid child layout objects * WIP: Recreating rows behaviour (almost working) * Major progress on rows * remove nested grid example (not supported) * Remove removal damn * Trying to use children directly * Ts fixes * chore: Fix TS * Fix issue when row bboxes when not updated on layout change * Now the tricky part * working * Removing some code * needs more work * Getting some thing working * Getting some thing working * fix toggle row * Starting to work * Fix * Yay it's working * Updates * Updates * Added some sorting of children * Updated comment * Simplify sorting * removed commented code * Updated * Pushed a fix so we can move a panel out from a row and into the parent grid * simplify move logic * Minor simplification * Removed some unnesary code * fixed comment * Removed unnessary condition in findGridSceneParent * remove unnessary if * Simplify toGridCell * removed duplicate if * removed unused code * Adds grid demo with different data scenarios * Make it green * Demo grid with multiple time ranges * Move child atomically * Add tests * Cleanup * Fix unused import Co-authored-by: Torkel Ödegaard Co-authored-by: Ivan Ortega --- .betterer.results | 9 +- .../scenes/components/NestedScene.test.tsx | 2 +- .../features/scenes/components/Scene.test.tsx | 2 +- .../scenes/components/SceneDragHandle.tsx | 18 + .../features/scenes/components/VizPanel.tsx | 9 +- .../{ => layout}/SceneFlexLayout.tsx | 4 +- .../layout/SceneGridLayout.test.tsx | 238 +++++++++ .../components/layout/SceneGridLayout.tsx | 493 ++++++++++++++++++ .../scenes/core/SceneComponentWrapper.tsx | 8 +- .../features/scenes/core/SceneObjectBase.tsx | 25 +- public/app/features/scenes/core/types.ts | 16 +- public/app/features/scenes/scenes/demo.tsx | 11 +- public/app/features/scenes/scenes/grid.tsx | 76 +++ .../scenes/scenes/gridMultiTimeRange.tsx | 109 ++++ .../features/scenes/scenes/gridMultiple.tsx | 120 +++++ .../scenes/scenes/gridWithMultipleData.tsx | 149 ++++++ .../features/scenes/scenes/gridWithRow.tsx | 97 ++++ .../features/scenes/scenes/gridWithRows.tsx | 102 ++++ public/app/features/scenes/scenes/index.tsx | 18 +- public/app/features/scenes/scenes/nested.tsx | 5 +- .../features/scenes/scenes/sceneWithRows.tsx | 4 +- .../features/scenes/scenes/variablesDemo.tsx | 2 +- 22 files changed, 1493 insertions(+), 24 deletions(-) create mode 100644 public/app/features/scenes/components/SceneDragHandle.tsx rename public/app/features/scenes/components/{ => layout}/SceneFlexLayout.tsx (94%) create mode 100644 public/app/features/scenes/components/layout/SceneGridLayout.test.tsx create mode 100644 public/app/features/scenes/components/layout/SceneGridLayout.tsx create mode 100644 public/app/features/scenes/scenes/grid.tsx create mode 100644 public/app/features/scenes/scenes/gridMultiTimeRange.tsx create mode 100644 public/app/features/scenes/scenes/gridMultiple.tsx create mode 100644 public/app/features/scenes/scenes/gridWithMultipleData.tsx create mode 100644 public/app/features/scenes/scenes/gridWithRow.tsx create mode 100644 public/app/features/scenes/scenes/gridWithRows.tsx diff --git a/.betterer.results b/.betterer.results index 0e01318e04a..face19f7e0f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4556,7 +4556,7 @@ exports[`better eslint`] = { "public/app/features/sandbox/TestStuffPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/scenes/components/SceneFlexLayout.tsx:5381": [ + "public/app/features/scenes/components/layout/SceneFlexLayout.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/scenes/core/SceneComponentWrapper.tsx:5381": [ @@ -4570,9 +4570,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, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/app/features/scenes/components/NestedScene.test.tsx b/public/app/features/scenes/components/NestedScene.test.tsx index 7a400e8447d..0b9d0b16355 100644 --- a/public/app/features/scenes/components/NestedScene.test.tsx +++ b/public/app/features/scenes/components/NestedScene.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { NestedScene } from './NestedScene'; import { Scene } from './Scene'; import { SceneCanvasText } from './SceneCanvasText'; -import { SceneFlexLayout } from './SceneFlexLayout'; +import { SceneFlexLayout } from './layout/SceneFlexLayout'; function setup() { const scene = new Scene({ diff --git a/public/app/features/scenes/components/Scene.test.tsx b/public/app/features/scenes/components/Scene.test.tsx index 6c616ff70f6..200210daeaf 100644 --- a/public/app/features/scenes/components/Scene.test.tsx +++ b/public/app/features/scenes/components/Scene.test.tsx @@ -1,5 +1,5 @@ import { Scene } from './Scene'; -import { SceneFlexLayout } from './SceneFlexLayout'; +import { SceneFlexLayout } from './layout/SceneFlexLayout'; describe('Scene', () => { it('Simple scene', () => { diff --git a/public/app/features/scenes/components/SceneDragHandle.tsx b/public/app/features/scenes/components/SceneDragHandle.tsx new file mode 100644 index 00000000000..f30130b86fd --- /dev/null +++ b/public/app/features/scenes/components/SceneDragHandle.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Icon } from '@grafana/ui'; + +export function SceneDragHandle({ layoutKey, className }: { layoutKey: string; className?: string }) { + return ( +
+ +
+ ); +} diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx index 764a5741c1f..375d4950c92 100644 --- a/public/app/features/scenes/components/VizPanel.tsx +++ b/public/app/features/scenes/components/VizPanel.tsx @@ -8,6 +8,8 @@ import { Field, PanelChrome, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; +import { SceneDragHandle } from './SceneDragHandle'; + export interface VizPanelState extends SceneLayoutChildState { title?: string; pluginId: string; @@ -33,8 +35,11 @@ export class VizPanel extends SceneObjectBase { } function ScenePanelRenderer({ model }: SceneComponentProps) { - const { title, pluginId, options, fieldConfig } = model.useState(); + const { title, pluginId, options, fieldConfig, ...state } = model.useState(); const { data } = model.getData().useState(); + const layout = model.getLayout(); + const isDraggable = layout.state.isDraggable ? state.isDraggable : false; + const dragHandle = ; return ( @@ -44,7 +49,7 @@ function ScenePanelRenderer({ model }: SceneComponentProps) { } return ( - + {(innerWidth, innerHeight) => ( <> + ({ children }: { children: (args: { width: number; height: number }) => React.ReactNode }) => + children({ height: 600, width: 600 }) +); + +class TestObject extends SceneObjectBase { + public static Component = (m: SceneComponentProps) => { + return
TestObject
; + }; +} + +describe('SceneGridLayout', () => { + describe('rendering', () => { + it('should render all grid children', async () => { + const scene = new Scene({ + title: 'Grid test', + layout: new SceneGridLayout({ + children: [ + new TestObject({ size: { x: 0, y: 0, width: 12, height: 5 } }), + new TestObject({ size: { x: 0, y: 5, width: 12, height: 5 } }), + ], + }), + }); + + render(); + + expect(screen.queryAllByTestId('test-object')).toHaveLength(2); + }); + + it('should not render children of a collapsed row', async () => { + const scene = new Scene({ + title: 'Grid test', + layout: new SceneGridLayout({ + children: [ + new TestObject({ key: 'a', size: { x: 0, y: 0, width: 12, height: 5 } }), + new TestObject({ key: 'b', size: { x: 0, y: 5, width: 12, height: 5 } }), + new SceneGridRow({ + title: 'Row A', + key: 'Row A', + isCollapsed: true, + size: { y: 10 }, + children: [new TestObject({ key: 'c', size: { x: 0, y: 11, width: 12, height: 5 } })], + }), + ], + }), + }); + + render(); + + expect(screen.queryAllByTestId('test-object')).toHaveLength(2); + }); + + it('should render children of an expanded row', async () => { + const scene = new Scene({ + title: 'Grid test', + layout: new SceneGridLayout({ + children: [ + new TestObject({ key: 'a', size: { x: 0, y: 0, width: 12, height: 5 } }), + new TestObject({ key: 'b', size: { x: 0, y: 5, width: 12, height: 5 } }), + new SceneGridRow({ + title: 'Row A', + key: 'Row A', + isCollapsed: false, + size: { y: 10 }, + children: [new TestObject({ key: 'c', size: { x: 0, y: 11, width: 12, height: 5 } })], + }), + ], + }), + }); + + render(); + + expect(screen.queryAllByTestId('test-object')).toHaveLength(3); + }); + }); + + describe('when moving a panel', () => { + it('shoud update layout children placement and order ', () => { + const layout = new SceneGridLayout({ + children: [ + new TestObject({ key: 'a', size: { x: 0, y: 0, width: 1, height: 1 } }), + new TestObject({ key: 'b', size: { x: 1, y: 0, width: 1, height: 1 } }), + new TestObject({ key: 'c', size: { x: 0, y: 1, width: 1, height: 1 } }), + ], + }); + layout.onDragStop( + [ + { i: 'b', x: 0, y: 0, w: 1, h: 1 }, + { + i: 'a', + x: 0, + y: 1, + w: 1, + h: 1, + }, + { + i: 'c', + x: 0, + y: 2, + w: 1, + h: 1, + }, + ], + // @ts-expect-error + {}, + { i: 'b', x: 0, y: 0, w: 1, h: 1 }, + {}, + {}, + {} + ); + + expect(layout.state.children[0].state.key).toEqual('b'); + expect(layout.state.children[0].state.size).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(layout.state.children[1].state.key).toEqual('a'); + expect(layout.state.children[1].state.size).toEqual({ x: 0, y: 1, width: 1, height: 1 }); + expect(layout.state.children[2].state.key).toEqual('c'); + expect(layout.state.children[2].state.size).toEqual({ x: 0, y: 2, width: 1, height: 1 }); + }); + }); + + describe('when using rows', () => { + it('should update objects relations when moving object out of a row', () => { + const rowAChild1 = new TestObject({ key: 'row-a-child1', size: { x: 0, y: 1, width: 1, height: 1 } }); + const rowAChild2 = new TestObject({ key: 'row-a-child2', size: { x: 1, y: 1, width: 1, height: 1 } }); + + const sourceRow = new SceneGridRow({ + title: 'Row A', + key: 'row-a', + children: [rowAChild1, rowAChild2], + size: { y: 0 }, + }); + + const layout = new SceneGridLayout({ + children: [sourceRow], + }); + + const updatedLayout = layout.moveChildTo(rowAChild1, layout); + + expect(updatedLayout.length).toEqual(2); + + // the source row should be cloned and with children updated + expect(updatedLayout[0].state.key).toEqual(sourceRow.state.key); + expect(updatedLayout[0]).not.toEqual(sourceRow); + expect((updatedLayout[0] as SceneGridRow).state.children.length).toEqual(1); + expect((updatedLayout[0] as SceneGridRow).state.children).not.toContain(rowAChild1); + + // the moved child should be cloned in the root + expect(updatedLayout[1].state.key).toEqual(rowAChild1.state.key); + expect(updatedLayout[1]).not.toEqual(rowAChild1); + }); + it('should update objects relations when moving objects between rows', () => { + const rowAChild1 = new TestObject({ key: 'row-a-child1', size: { x: 0, y: 0, width: 1, height: 1 } }); + const rowAChild2 = new TestObject({ key: 'row-a-child2', size: { x: 1, y: 0, width: 1, height: 1 } }); + + const sourceRow = new SceneGridRow({ + title: 'Row A', + key: 'row-a', + children: [rowAChild1, rowAChild2], + }); + + const targetRow = new SceneGridRow({ + title: 'Row B', + key: 'row-b', + children: [], + }); + + const panelOutsideARow = new TestObject({ key: 'a', size: { x: 0, y: 0, width: 1, height: 1 } }); + const layout = new SceneGridLayout({ + children: [panelOutsideARow, sourceRow, targetRow], + }); + + const updatedLayout = layout.moveChildTo(rowAChild1, targetRow); + + expect(updatedLayout[0]).toEqual(panelOutsideARow); + + // the source row should be cloned and with children updated + expect(updatedLayout[1].state.key).toEqual(sourceRow.state.key); + expect(updatedLayout[1]).not.toEqual(sourceRow); + expect((updatedLayout[1] as SceneGridRow).state.children.length).toEqual(1); + + // the target row should be cloned and with children updated + expect(updatedLayout[2].state.key).toEqual(targetRow.state.key); + expect(updatedLayout[2]).not.toEqual(targetRow); + expect((updatedLayout[2] as SceneGridRow).state.children.length).toEqual(1); + + // the moved object should be cloned and added to the target row + const movedObject = (updatedLayout[2] as SceneGridRow).state.children[0]; + expect(movedObject.state.key).toEqual('row-a-child1'); + expect(movedObject).not.toEqual(rowAChild1); + }); + + it('should update position of objects when row is expanded', () => { + const rowAChild1 = new TestObject({ key: 'row-a-child1', size: { x: 0, y: 1, width: 1, height: 1 } }); + const rowAChild2 = new TestObject({ key: 'row-a-child2', size: { x: 1, y: 1, width: 1, height: 1 } }); + + const rowA = new SceneGridRow({ + title: 'Row A', + key: 'row-a', + children: [rowAChild1, rowAChild2], + size: { y: 0 }, + isCollapsed: true, + }); + + const panelOutsideARow = new TestObject({ key: 'outsider', size: { x: 0, y: 1, width: 1, height: 1 } }); + + const rowBChild1 = new TestObject({ key: 'row-b-child1', size: { x: 0, y: 3, width: 1, height: 1 } }); + const rowB = new SceneGridRow({ + title: 'Row B', + key: 'row-b', + children: [rowBChild1], + size: { y: 2 }, + isCollapsed: false, + }); + + const layout = new SceneGridLayout({ + children: [rowA, panelOutsideARow, rowB], + }); + + layout.toggleRow(rowA); + + expect(panelOutsideARow.state!.size!.y).toEqual(2); + expect(rowB.state!.size!.y).toEqual(3); + expect(rowBChild1.state!.size!.y).toEqual(4); + }); + }); +}); diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.tsx new file mode 100644 index 00000000000..645a110bcec --- /dev/null +++ b/public/app/features/scenes/components/layout/SceneGridLayout.tsx @@ -0,0 +1,493 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; +import ReactGridLayout from 'react-grid-layout'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, useStyles2 } from '@grafana/ui'; +import { DEFAULT_PANEL_SPAN, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; + +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { + SceneComponentProps, + SceneLayoutChild, + SceneLayoutChildState, + SceneLayoutState, + SceneObject, + SceneObjectSize, +} from '../../core/types'; +import { SceneDragHandle } from '../SceneDragHandle'; + +interface SceneGridLayoutState extends SceneLayoutState {} + +export class SceneGridLayout extends SceneObjectBase { + public static Component = SceneGridLayoutRenderer; + + private _skipOnLayoutChange = false; + + public constructor(state: SceneGridLayoutState) { + super({ + isDraggable: true, + ...state, + children: sortChildrenByPosition(state.children), + }); + } + + public toggleRow(row: SceneGridRow) { + const isCollapsed = row.state.isCollapsed; + + if (!isCollapsed) { + row.setState({ isCollapsed: true }); + // To force re-render + this.setState({}); + return; + } + + const rowChildren = row.state.children; + + if (rowChildren.length === 0) { + row.setState({ isCollapsed: false }); + this.setState({}); + return; + } + + // Ok we are expanding row. We need to update row children y pos (incase they are incorrect) and push items below down + // Code copied from DashboardModel toggleRow() + + const rowY = row.state.size?.y!; + const firstPanelYPos = rowChildren[0].state.size?.y ?? rowY; + const yDiff = firstPanelYPos - (rowY + 1); + + // y max will represent the bottom y pos after all panels have been added + // needed to know home much panels below should be pushed down + let yMax = rowY; + + for (const panel of rowChildren) { + // set the y gridPos if it wasn't already set + const newSize = { ...panel.state.size }; + newSize.y = newSize.y ?? rowY; + // make sure y is adjusted (in case row moved while collapsed) + newSize.y -= yDiff; + if (newSize.y > panel.state.size?.y!) { + panel.setState({ size: newSize }); + } + // update insert post and y max + yMax = Math.max(yMax, Number(newSize.y!) + Number(newSize.height!)); + } + + const pushDownAmount = yMax - rowY - 1; + + // push panels below down + for (const child of this.state.children) { + if (child.state.size?.y! > rowY) { + this.pushChildDown(child, pushDownAmount); + } + + if (child instanceof SceneGridRow && child !== row) { + for (const rowChild of child.state.children) { + if (rowChild.state.size?.y! > rowY) { + this.pushChildDown(rowChild, pushDownAmount); + } + } + } + } + + row.setState({ isCollapsed: false }); + // Trigger re-render + this.setState({}); + } + + public onLayoutChange = (layout: ReactGridLayout.Layout[]) => { + if (this._skipOnLayoutChange) { + // Layout has been updated by other RTL handler already + this._skipOnLayoutChange = false; + return; + } + + for (const item of layout) { + const child = this.getSceneLayoutChild(item.i); + + const nextSize = { + x: item.x, + y: item.y, + width: item.w, + height: item.h, + }; + + if (!isItemSizeEqual(child.state.size!, nextSize)) { + child.setState({ + size: { + ...child.state.size, + ...nextSize, + }, + }); + } + } + + this.setState({ children: sortChildrenByPosition(this.state.children) }); + }; + + /** + * Will also scan row children and return child of the row + */ + public getSceneLayoutChild(key: string) { + for (const child of this.state.children) { + if (child.state.key === key) { + return child; + } + + if (child instanceof SceneGridRow) { + for (const rowChild of child.state.children) { + if (rowChild.state.key === key) { + return rowChild; + } + } + } + } + + throw new Error('Scene layout child not found for GridItem'); + } + + public onResizeStop: ReactGridLayout.ItemCallback = (_, o, n) => { + const child = this.getSceneLayoutChild(n.i); + child.setState({ + size: { + ...child.state.size, + width: n.w, + height: n.h, + }, + }); + }; + + private pushChildDown(child: SceneLayoutChild, amount: number) { + child.setState({ + size: { + ...child.state.size, + y: child.state.size?.y! + amount, + }, + }); + } + + /** + * We assume the layout array is storted according to y pos, and walk upwards until we find a row. + * If it is collapsed there is no row to add it to. The default is then to return the SceneGridLayout itself + */ + private findGridItemSceneParent(layout: ReactGridLayout.Layout[], startAt: number): SceneGridRow | SceneGridLayout { + for (let i = startAt; i >= 0; i--) { + const gridItem = layout[i]; + const sceneChild = this.getSceneLayoutChild(gridItem.i); + + if (sceneChild instanceof SceneGridRow) { + // the closest row is collapsed return null + if (sceneChild.state.isCollapsed) { + return this; + } + + return sceneChild; + } + } + + return this; + } + + /** + * This likely needs a slighltly different approach. Where we clone or deactivate or and re-activate the moved child + */ + public moveChildTo(child: SceneLayoutChild, target: SceneGridLayout | SceneGridRow) { + const currentParent = child.parent!; + let rootChildren = this.state.children; + const newChild = child.clone({ key: child.state.key }); + + // Remove from current parent row + if (currentParent instanceof SceneGridRow) { + const newRow = currentParent.clone({ + children: currentParent.state.children.filter((c) => c.state.key !== child.state.key), + }); + + // new children with new row + rootChildren = rootChildren.map((c) => (c === currentParent ? newRow : c)); + + // if target is also a row + if (target instanceof SceneGridRow) { + const targetRow = target.clone({ children: [...target.state.children, newChild] }); + rootChildren = rootChildren.map((c) => (c === target ? targetRow : c)); + } else { + // target is the main grid + rootChildren = [...rootChildren, newChild]; + } + } else { + // current parent is the main grid remove it from there + rootChildren = rootChildren.filter((c) => c.state.key !== child.state.key); + // Clone the target row and add the child + const targetRow = target.clone({ children: [...target.state.children, newChild] }); + // Replace row with new row + rootChildren = rootChildren.map((c) => (c === target ? targetRow : c)); + } + + return rootChildren; + } + + public onDragStop: ReactGridLayout.ItemCallback = (gridLayout, o, updatedItem) => { + const sceneChild = this.getSceneLayoutChild(updatedItem.i)!; + + // Need to resort the grid layout based on new position (needed to to find the new parent) + gridLayout = sortGridLayout(gridLayout); + + // Update children positions if they have changed + for (let i = 0; i < gridLayout.length; i++) { + const gridItem = gridLayout[i]; + const child = this.getSceneLayoutChild(gridItem.i)!; + const childSize = child.state.size!; + + if (childSize?.x !== gridItem.x || childSize?.y !== gridItem.y) { + child.setState({ + size: { + ...child.state.size, + x: gridItem.x, + y: gridItem.y, + }, + }); + } + } + + // Update the parent if the child if it has moved to a row or back to the grid + const indexOfUpdatedItem = gridLayout.findIndex((item) => item.i === updatedItem.i); + const newParent = this.findGridItemSceneParent(gridLayout, indexOfUpdatedItem - 1); + let newChildren = this.state.children; + + if (newParent !== sceneChild.parent) { + newChildren = this.moveChildTo(sceneChild, newParent); + } + + this.setState({ children: sortChildrenByPosition(newChildren) }); + this._skipOnLayoutChange = true; + }; + + private toGridCell(child: SceneLayoutChild): ReactGridLayout.Layout { + const size = child.state.size!; + + let x = size.x ?? 0; + let y = size.y ?? 0; + const w = Number.isInteger(Number(size.width)) ? Number(size.width) : DEFAULT_PANEL_SPAN; + const h = Number.isInteger(Number(size.height)) ? Number(size.height) : DEFAULT_PANEL_SPAN; + + let isDraggable = Boolean(child.state.isDraggable); + let isResizable = Boolean(child.state.isResizable); + + if (child instanceof SceneGridRow) { + isDraggable = child.state.isCollapsed ? true : false; + isResizable = false; + } + + return { i: child.state.key!, x, y, h, w, isResizable, isDraggable }; + } + + public buildGridLayout(width: number): ReactGridLayout.Layout[] { + let cells: ReactGridLayout.Layout[] = []; + + for (const child of this.state.children) { + cells.push(this.toGridCell(child)); + + if (child instanceof SceneGridRow && !child.state.isCollapsed) { + for (const rowChild of child.state.children) { + cells.push(this.toGridCell(rowChild)); + } + } + } + + // Sort by position + cells = sortGridLayout(cells); + + if (width < 768) { + // We should not persist the mobile layout + this._skipOnLayoutChange = true; + return cells.map((cell) => ({ ...cell, w: 24 })); + } + + this._skipOnLayoutChange = false; + + return cells; + } +} + +function SceneGridLayoutRenderer({ model }: SceneComponentProps) { + const { children } = model.useState(); + validateChildrenSize(children); + + return ( + + {({ width }) => { + if (width === 0) { + return null; + } + + const layout = model.buildGridLayout(width); + + return ( + /** + * The children is using a width of 100% so we need to guarantee that it is wrapped + * in an element that has the calculated size given by the AutoSizer. The AutoSizer + * has a width of 0 and will let its content overflow its div. + */ +
+ 768} + isResizable={false} + containerPadding={[0, 0]} + useCSSTransforms={false} + margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]} + cols={GRID_COLUMN_COUNT} + rowHeight={GRID_CELL_HEIGHT} + draggableHandle={`.grid-drag-handle-${model.state.key}`} + // @ts-ignore: ignoring for now until we make the size type numbers-only + layout={layout} + onDragStop={model.onDragStop} + onResizeStop={model.onResizeStop} + onLayoutChange={model.onLayoutChange} + isBounded={false} + > + {layout.map((gridItem) => { + const sceneChild = model.getSceneLayoutChild(gridItem.i)!; + return ( +
+ +
+ ); + })} +
+
+ ); + }} +
+ ); +} + +interface SceneGridRowState extends SceneLayoutChildState { + title: string; + isCollapsible?: boolean; + isCollapsed?: boolean; + children: Array>; +} + +export class SceneGridRow extends SceneObjectBase { + public static Component = SceneGridRowRenderer; + + public constructor(state: SceneGridRowState) { + super({ + isResizable: false, + isDraggable: true, + isCollapsible: true, + ...state, + size: { + ...state.size, + x: 0, + height: 1, + width: GRID_COLUMN_COUNT, + }, + }); + } + + public onCollapseToggle = () => { + if (!this.state.isCollapsible) { + return; + } + + const layout = this.parent; + + if (!layout || !(layout instanceof SceneGridLayout)) { + throw new Error('SceneGridRow must be a child of SceneGridLayout'); + } + + layout.toggleRow(this); + }; +} + +function SceneGridRowRenderer({ model }: SceneComponentProps) { + const styles = useStyles2(getSceneGridRowStyles); + const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); + const layout = model.getLayout(); + const dragHandle = ; + + return ( +
+
+
+ {isCollapsible && } + {title} +
+ {isDraggable && isCollapsed &&
{dragHandle}
} +
+
+ ); +} + +const getSceneGridRowStyles = (theme: GrafanaTheme2) => { + return { + row: css({ + width: '100%', + height: '100%', + position: 'relative', + zIndex: 0, + display: 'flex', + flexDirection: 'column', + }), + rowHeader: css({ + width: '100%', + height: '30px', + display: 'flex', + justifyContent: 'space-between', + marginBottom: '8px', + border: `1px solid transparent`, + }), + rowTitleWrapper: css({ + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + }), + rowHeaderCollapsed: css({ + marginBottom: '0px', + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.borderRadius(1), + }), + rowTitle: css({ + fontSize: theme.typography.h6.fontSize, + fontWeight: theme.typography.h6.fontWeight, + }), + }; +}; + +function validateChildrenSize(children: SceneLayoutChild[]) { + if ( + children.find( + (c) => + !c.state.size || + c.state.size.height === undefined || + c.state.size.width === undefined || + c.state.size.x === undefined || + c.state.size.y === undefined + ) + ) { + throw new Error('All children must have a size specified'); + } +} + +function isItemSizeEqual(a: SceneObjectSize, b: SceneObjectSize) { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} + +function sortChildrenByPosition(children: SceneLayoutChild[]) { + return [...children].sort((a, b) => { + return a.state.size?.y! - b.state.size?.y! || a.state.size?.x! - b.state.size?.x!; + }); +} + +function sortGridLayout(layout: ReactGridLayout.Layout[]) { + return [...layout].sort((a, b) => a.y - b.y || a.x! - b.x); +} diff --git a/public/app/features/scenes/core/SceneComponentWrapper.tsx b/public/app/features/scenes/core/SceneComponentWrapper.tsx index 74cfb834d6b..e4a6f71ece7 100644 --- a/public/app/features/scenes/core/SceneComponentWrapper.tsx +++ b/public/app/features/scenes/core/SceneComponentWrapper.tsx @@ -4,9 +4,13 @@ import { SceneComponentEditingWrapper } from '../editor/SceneComponentEditWrappe import { SceneComponentProps, SceneObject } from './types'; -export function SceneComponentWrapper({ model, isEditing }: SceneComponentProps) { +export function SceneComponentWrapper({ + model, + isEditing, + ...otherProps +}: SceneComponentProps) { const Component = (model as any).constructor['Component'] ?? EmptyRenderer; - const inner = ; + const inner = ; // Handle component activation state state useEffect(() => { diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index 8c4e029e16d..7c25982416e 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -7,7 +7,15 @@ import { useForceUpdate } from '@grafana/ui'; import { SceneComponentWrapper } from './SceneComponentWrapper'; import { SceneObjectStateChangedEvent } from './events'; -import { SceneDataState, SceneObject, SceneComponent, SceneEditor, SceneTimeRange, SceneObjectState } from './types'; +import { + SceneDataState, + SceneObject, + SceneComponent, + SceneEditor, + SceneTimeRange, + SceneObjectState, + SceneLayoutState, +} from './types'; export abstract class SceneObjectBase implements SceneObject @@ -208,6 +216,21 @@ export abstract class SceneObjectBase { + if (this.constructor.name === 'SceneFlexLayout' || this.constructor.name === 'SceneGridLayout') { + return this as SceneObject; + } + + if (this.parent) { + return this.parent.getLayout(); + } + + throw new Error('No layout found in scene tree'); + } + /** * Will walk up the scene object graph to the closest $editor scene object */ diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index b531d6f95e6..86e678184b4 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -13,9 +13,20 @@ export interface SceneObjectStatePlain { $variables?: SceneVariables; } -export interface SceneLayoutChildState extends SceneObjectStatePlain { +export interface SceneLayoutChildSize { size?: SceneObjectSize; } +export interface SceneLayoutChildInteractions { + isDraggable?: boolean; + isResizable?: boolean; + isCollapsible?: boolean; + isCollapsed?: boolean; +} + +export interface SceneLayoutChildState + extends SceneObjectStatePlain, + SceneLayoutChildSize, + SceneLayoutChildInteractions {} export type SceneObjectState = SceneObjectStatePlain | SceneLayoutState | SceneLayoutChildState; @@ -84,6 +95,9 @@ export interface SceneObject /** Get the closest node with time range */ getTimeRange(): SceneTimeRange; + /** Get the closest layout node */ + getLayout(): SceneObject; + /** Returns a deep clone this object and all its children */ clone(state?: Partial): this; diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx index 21066bf7007..f6acf02703d 100644 --- a/public/app/features/scenes/scenes/demo.tsx +++ b/public/app/features/scenes/scenes/demo.tsx @@ -2,11 +2,11 @@ import { getDefaultTimeRange } from '@grafana/data'; import { Scene } from '../components/Scene'; import { SceneCanvasText } from '../components/SceneCanvasText'; -import { SceneFlexLayout } from '../components/SceneFlexLayout'; import { ScenePanelRepeater } from '../components/ScenePanelRepeater'; import { SceneTimePicker } from '../components/SceneTimePicker'; import { SceneToolbarInput } from '../components/SceneToolbarButton'; import { VizPanel } from '../components/VizPanel'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; import { SceneQueryRunner } from '../querying/SceneQueryRunner'; @@ -18,12 +18,12 @@ export function getFlexLayoutTest(): Scene { direction: 'row', children: [ new VizPanel({ + size: { minWidth: '70%' }, pluginId: 'timeseries', title: 'Dynamic height and width', - size: { minWidth: '70%' }, }), + new SceneFlexLayout({ - // size: { width: 450 }, direction: 'column', children: [ new VizPanel({ @@ -35,15 +35,15 @@ export function getFlexLayoutTest(): Scene { title: 'Fill height', }), new SceneCanvasText({ + size: { ySizing: 'content' }, text: 'Size to content', fontSize: 20, - size: { ySizing: 'content' }, align: 'center', }), new VizPanel({ + size: { height: 300 }, pluginId: 'timeseries', title: 'Fixed height', - size: { height: 300 }, }), ], }), @@ -92,6 +92,7 @@ export function getScenePanelRepeaterTest(): Scene { direction: 'column', children: [ new SceneFlexLayout({ + direction: 'row', size: { minHeight: 200 }, children: [ new VizPanel({ diff --git a/public/app/features/scenes/scenes/grid.tsx b/public/app/features/scenes/scenes/grid.tsx new file mode 100644 index 00000000000..cefa6b61b9e --- /dev/null +++ b/public/app/features/scenes/scenes/grid.tsx @@ -0,0 +1,76 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; +import { SceneGridLayout } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getGridLayoutTest(): Scene { + const scene = new Scene({ + title: 'Grid layout test', + layout: new SceneGridLayout({ + children: [ + new VizPanel({ + isResizable: true, + isDraggable: true, + pluginId: 'timeseries', + title: 'Draggable and resizable', + size: { + x: 0, + y: 0, + width: 12, + height: 10, + }, + }), + + new VizPanel({ + pluginId: 'timeseries', + title: 'No drag and no resize', + isResizable: false, + isDraggable: false, + size: { x: 12, y: 0, width: 12, height: 10 }, + }), + + new SceneFlexLayout({ + direction: 'column', + isDraggable: true, + isResizable: true, + size: { x: 6, y: 11, width: 12, height: 10 }, + children: [ + new VizPanel({ + size: { ySizing: 'fill' }, + pluginId: 'timeseries', + title: 'Child of flex layout', + }), + new VizPanel({ + size: { ySizing: 'fill' }, + pluginId: 'timeseries', + title: 'Child of flex layout', + }), + ], + }), + ], + }), + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx new file mode 100644 index 00000000000..f58854a99ce --- /dev/null +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -0,0 +1,109 @@ +import { dateTime, getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getGridWithMultipleTimeRanges(): Scene { + const globalTimeRange = new SceneTimeRange(getDefaultTimeRange()); + + const now = dateTime(); + const row1TimeRange = new SceneTimeRange({ + from: dateTime(now).subtract(1, 'year'), + to: now, + raw: { from: 'now-1y', to: 'now' }, + }); + + const scene = new Scene({ + title: 'Grid with rows and different queries and time ranges', + layout: new SceneGridLayout({ + children: [ + new SceneGridRow({ + $timeRange: row1TimeRange, + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk_table', + }, + ], + }), + title: 'Row A - has its own query, last year time range', + key: 'Row A', + isCollapsed: true, + size: { y: 0 }, + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Row A Child1', + key: 'Row A Child1', + isResizable: true, + isDraggable: true, + size: { x: 0, y: 1, width: 12, height: 5 }, + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Row A Child2', + key: 'Row A Child2', + isResizable: true, + isDraggable: true, + size: { x: 0, y: 5, width: 6, height: 5 }, + }), + ], + }), + + new VizPanel({ + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + seriesCount: 10, + }, + ], + }), + isResizable: true, + isDraggable: true, + pluginId: 'timeseries', + title: 'Outsider, has its own query', + key: 'Outsider-own-query', + size: { + x: 0, + y: 12, + width: 6, + height: 10, + }, + }), + ], + }), + $editor: new SceneEditManager({}), + $timeRange: globalTimeRange, + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/gridMultiple.tsx b/public/app/features/scenes/scenes/gridMultiple.tsx new file mode 100644 index 00000000000..bb4044c9e76 --- /dev/null +++ b/public/app/features/scenes/scenes/gridMultiple.tsx @@ -0,0 +1,120 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; +import { SceneGridLayout } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getMultipleGridLayoutTest(): Scene { + const scene = new Scene({ + title: 'Multiple grid layouts test', + layout: new SceneFlexLayout({ + children: [ + new SceneGridLayout({ + children: [ + new VizPanel({ + size: { + x: 0, + y: 0, + width: 12, + height: 10, + }, + isDraggable: true, + isResizable: true, + pluginId: 'timeseries', + title: 'Dragabble and resizable', + }), + new VizPanel({ + isResizable: false, + isDraggable: true, + size: { x: 12, y: 0, width: 12, height: 10 }, + pluginId: 'timeseries', + title: 'Draggable only', + }), + new SceneFlexLayout({ + isResizable: true, + isDraggable: true, + size: { x: 6, y: 11, width: 12, height: 10 }, + direction: 'column', + children: [ + new VizPanel({ + size: { ySizing: 'fill' }, + pluginId: 'timeseries', + title: 'Fill height', + }), + new VizPanel({ + size: { ySizing: 'fill' }, + pluginId: 'timeseries', + title: 'Fill height', + }), + ], + }), + ], + }), + + new SceneGridLayout({ + children: [ + new VizPanel({ + size: { + x: 0, + y: 0, + width: 12, + height: 10, + }, + isDraggable: true, + pluginId: 'timeseries', + title: 'Fill height', + }), + new VizPanel({ + isResizable: false, + isDraggable: true, + size: { x: 12, y: 0, width: 12, height: 10 }, + pluginId: 'timeseries', + title: 'Fill height', + }), + new SceneFlexLayout({ + size: { x: 6, y: 11, width: 12, height: 10 }, + direction: 'column', + children: [ + new VizPanel({ + size: { ySizing: 'fill' }, + isDraggable: true, + pluginId: 'timeseries', + title: 'Fill height', + }), + new VizPanel({ + isDraggable: true, + size: { ySizing: 'fill' }, + pluginId: 'timeseries', + title: 'Fill height', + }), + ], + }), + ], + }), + ], + }), + + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx new file mode 100644 index 00000000000..d492f81b057 --- /dev/null +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -0,0 +1,149 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getGridWithMultipleData(): Scene { + const scene = new Scene({ + title: 'Grid with rows and different queries', + layout: new SceneGridLayout({ + children: [ + new SceneGridRow({ + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk_table', + }, + ], + }), + title: 'Row A - has its own query', + key: 'Row A', + isCollapsed: true, + size: { y: 0 }, + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Row A Child1', + key: 'Row A Child1', + isResizable: true, + isDraggable: true, + size: { x: 0, y: 1, width: 12, height: 5 }, + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Row A Child2', + key: 'Row A Child2', + isResizable: true, + isDraggable: true, + size: { x: 0, y: 5, width: 6, height: 5 }, + }), + ], + }), + new SceneGridRow({ + title: 'Row B - uses global query', + key: 'Row B', + isCollapsed: true, + size: { y: 1 }, + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Row B Child1', + key: 'Row B Child1', + isResizable: false, + isDraggable: true, + size: { x: 0, y: 2, width: 12, height: 5 }, + }), + new VizPanel({ + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + seriesCount: 10, + }, + ], + }), + pluginId: 'timeseries', + title: 'Row B Child2 with data', + key: 'Row B Child2', + isResizable: false, + isDraggable: true, + size: { x: 0, y: 7, width: 6, height: 5 }, + }), + ], + }), + new VizPanel({ + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + seriesCount: 10, + }, + ], + }), + isResizable: true, + isDraggable: true, + pluginId: 'timeseries', + title: 'Outsider, has its own query', + key: 'Outsider-own-query', + size: { + x: 0, + y: 12, + width: 6, + height: 10, + }, + }), + new VizPanel({ + isResizable: true, + isDraggable: true, + pluginId: 'timeseries', + title: 'Outsider, uses global query', + key: 'Outsider-global-query', + size: { + x: 6, + y: 12, + width: 12, + height: 10, + }, + }), + ], + }), + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/gridWithRow.tsx b/public/app/features/scenes/scenes/gridWithRow.tsx new file mode 100644 index 00000000000..cbda038b18c --- /dev/null +++ b/public/app/features/scenes/scenes/gridWithRow.tsx @@ -0,0 +1,97 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getGridWithRowLayoutTest(): Scene { + const scene = new Scene({ + title: 'Grid with row layout test', + layout: new SceneGridLayout({ + children: [ + new SceneGridRow({ + title: 'Row A', + key: 'Row A', + isCollapsed: true, + size: { y: 0 }, + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Row A Child1', + key: 'Row A Child1', + isResizable: true, + isDraggable: true, + size: { x: 0, y: 1, width: 12, height: 5 }, + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Row A Child2', + key: 'Row A Child2', + isResizable: true, + isDraggable: true, + size: { x: 0, y: 5, width: 6, height: 5 }, + }), + ], + }), + new SceneGridRow({ + title: 'Row B', + key: 'Row B', + isCollapsed: true, + size: { y: 1 }, + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Row B Child1', + key: 'Row B Child1', + isResizable: false, + isDraggable: true, + size: { x: 0, y: 2, width: 12, height: 5 }, + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Row B Child2', + key: 'Row B Child2', + isResizable: false, + isDraggable: true, + size: { x: 0, y: 7, width: 6, height: 5 }, + }), + ], + }), + new VizPanel({ + isResizable: true, + isDraggable: true, + pluginId: 'timeseries', + title: 'Outsider', + key: 'Outsider', + size: { + x: 2, + y: 12, + width: 12, + height: 10, + }, + }), + ], + }), + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/gridWithRows.tsx b/public/app/features/scenes/scenes/gridWithRows.tsx new file mode 100644 index 00000000000..609373dd256 --- /dev/null +++ b/public/app/features/scenes/scenes/gridWithRows.tsx @@ -0,0 +1,102 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; +import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getGridWithRowsTest(): Scene { + const panel = new VizPanel({ + pluginId: 'timeseries', + title: 'Fill height', + }); + + const row1 = new SceneGridRow({ + title: 'Collapsible/draggable row with flex layout', + size: { x: 0, y: 0, height: 10 }, + children: [ + new SceneFlexLayout({ + direction: 'row', + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Fill height', + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Fill height', + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Fill height', + }), + ], + }), + ], + }); + + const cell1 = new VizPanel({ + size: { + x: 0, + y: 10, + width: 12, + height: 20, + }, + pluginId: 'timeseries', + title: 'Cell 1', + }); + + const cell2 = new VizPanel({ + isResizable: false, + isDraggable: false, + size: { x: 12, y: 20, width: 12, height: 10 }, + pluginId: 'timeseries', + title: 'No resize/no drag', + }); + + const row2 = new SceneGridRow({ + size: { x: 12, y: 10, height: 10, width: 12 }, + title: 'Row with a nested flex layout', + children: [ + new SceneFlexLayout({ + children: [ + new SceneFlexLayout({ + direction: 'column', + children: [panel, panel], + }), + new SceneFlexLayout({ + direction: 'column', + children: [panel, panel], + }), + ], + }), + ], + }); + const scene = new Scene({ + title: 'Grid rows test', + layout: new SceneGridLayout({ + children: [cell1, cell2, row1, row2], + }), + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx index 0a1db6b2830..d8900a858b2 100644 --- a/public/app/features/scenes/scenes/index.tsx +++ b/public/app/features/scenes/scenes/index.tsx @@ -1,12 +1,28 @@ import { Scene } from '../components/Scene'; import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo'; +import { getGridLayoutTest } from './grid'; +import { getGridWithMultipleTimeRanges } from './gridMultiTimeRange'; +import { getMultipleGridLayoutTest } from './gridMultiple'; +import { getGridWithMultipleData } from './gridWithMultipleData'; +import { getGridWithRowLayoutTest } from './gridWithRow'; import { getNestedScene } from './nested'; import { getSceneWithRows } from './sceneWithRows'; import { getVariablesDemo } from './variablesDemo'; export function getScenes(): Scene[] { - return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene(), getSceneWithRows(), getVariablesDemo()]; + return [ + getFlexLayoutTest(), + getScenePanelRepeaterTest(), + getNestedScene(), + getSceneWithRows(), + getGridLayoutTest(), + getGridWithRowLayoutTest(), + getGridWithMultipleData(), + getGridWithMultipleTimeRanges(), + getMultipleGridLayoutTest(), + getVariablesDemo(), + ]; } const cache: Record = {}; diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx index 77684fc4684..4b99f9bb497 100644 --- a/public/app/features/scenes/scenes/nested.tsx +++ b/public/app/features/scenes/scenes/nested.tsx @@ -2,9 +2,9 @@ import { getDefaultTimeRange } from '@grafana/data'; import { NestedScene } from '../components/NestedScene'; import { Scene } from '../components/Scene'; -import { SceneFlexLayout } from '../components/SceneFlexLayout'; import { SceneTimePicker } from '../components/SceneTimePicker'; import { VizPanel } from '../components/VizPanel'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneQueryRunner } from '../querying/SceneQueryRunner'; @@ -14,12 +14,12 @@ export function getNestedScene(): Scene { layout: new SceneFlexLayout({ direction: 'column', children: [ + getInnerScene('Inner scene'), new VizPanel({ key: '3', pluginId: 'timeseries', title: 'Panel 3', }), - getInnerScene('Inner scene'), ], }), $timeRange: new SceneTimeRange(getDefaultTimeRange()), @@ -45,6 +45,7 @@ export function getInnerScene(title: string) { const scene = new NestedScene({ title: title, canRemove: true, + canCollapse: true, layout: new SceneFlexLayout({ direction: 'row', children: [ diff --git a/public/app/features/scenes/scenes/sceneWithRows.tsx b/public/app/features/scenes/scenes/sceneWithRows.tsx index a45282778d0..efcc172f053 100644 --- a/public/app/features/scenes/scenes/sceneWithRows.tsx +++ b/public/app/features/scenes/scenes/sceneWithRows.tsx @@ -2,9 +2,9 @@ import { getDefaultTimeRange } from '@grafana/data'; import { NestedScene } from '../components/NestedScene'; import { Scene } from '../components/Scene'; -import { SceneFlexLayout } from '../components/SceneFlexLayout'; import { SceneTimePicker } from '../components/SceneTimePicker'; import { VizPanel } from '../components/VizPanel'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -19,6 +19,7 @@ export function getSceneWithRows(): Scene { new NestedScene({ title: 'Overview', canCollapse: true, + // size: { ySizing: 'content', xSizing: 'fill' }, layout: new SceneFlexLayout({ direction: 'row', children: [ @@ -35,6 +36,7 @@ export function getSceneWithRows(): Scene { }), new NestedScene({ title: 'More server details', + // size: { ySizing: 'content', xSizing: 'fill' }, canCollapse: true, layout: new SceneFlexLayout({ direction: 'row', diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx index 45ae1149d8c..c6b4f1da6f3 100644 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -2,9 +2,9 @@ import { getDefaultTimeRange } from '@grafana/data'; import { Scene } from '../components/Scene'; import { SceneCanvasText } from '../components/SceneCanvasText'; -import { SceneFlexLayout } from '../components/SceneFlexLayout'; import { SceneSubMenu } from '../components/SceneSubMenu'; import { SceneTimePicker } from '../components/SceneTimePicker'; +import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { VariableValueSelectors } from '../variables/components/VariableValueSelectors'; import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; From d999b5bda0bef78f8d08de99e50b474888ea071e Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Tue, 15 Nov 2022 09:51:40 +0100 Subject: [PATCH 237/926] RBAC: remove redundant role name field from plugin role registrations (#58166) * RBAC: Remove name from role registration * Inline accesscontrol service * test fix * use fmt Co-Authored-By: marefr Co-authored-by: marefr --- pkg/coremodel/pluginmeta/coremodel.cue | 2 +- pkg/coremodel/pluginmeta/pluginmeta_gen.go | 2 -- pkg/plugins/manager/loader/loader_test.go | 3 +-- .../testdata/test-app-with-roles/MANIFEST.txt | 16 +++++------ .../testdata/test-app-with-roles/plugin.json | 3 +-- pkg/plugins/models.go | 1 - pkg/services/accesscontrol/acimpl/service.go | 2 +- .../accesscontrol/acimpl/service_test.go | 27 ++++++------------- .../accesscontrol/pluginutils/utils.go | 11 +++++--- .../accesscontrol/pluginutils/utils_test.go | 18 ++++++------- 10 files changed, 37 insertions(+), 48 deletions(-) diff --git a/pkg/coremodel/pluginmeta/coremodel.cue b/pkg/coremodel/pluginmeta/coremodel.cue index d5f61a14556..15a51a69197 100644 --- a/pkg/coremodel/pluginmeta/coremodel.cue +++ b/pkg/coremodel/pluginmeta/coremodel.cue @@ -158,7 +158,7 @@ seqs: [ // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. #Role: { name: string, - displayName: string, + name: =~"^([A-Z][0-9A-Za-z ]+)$" description: string, permissions: [...#Permission] } diff --git a/pkg/coremodel/pluginmeta/pluginmeta_gen.go b/pkg/coremodel/pluginmeta/pluginmeta_gen.go index f432a87cb5f..7de04ea12da 100644 --- a/pkg/coremodel/pluginmeta/pluginmeta_gen.go +++ b/pkg/coremodel/pluginmeta/pluginmeta_gen.go @@ -537,7 +537,6 @@ type ReleaseState string // Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Role struct { Description string `json:"description"` - DisplayName string `json:"displayName"` Name string `json:"name"` Permissions []struct { Action string `json:"action"` @@ -562,7 +561,6 @@ type RoleRegistration struct { // RBAC role definition to bundle related RBAC permissions on the plugin. Role struct { Description string `json:"description"` - DisplayName string `json:"displayName"` Name string `json:"name"` Permissions []struct { Action string `json:"action"` diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index ec263f3e17f..bb03cd48cf5 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -646,8 +646,7 @@ func TestLoader_Load_RBACReady(t *testing.T) { Roles: []plugins.RoleRegistration{ { Role: plugins.Role{ - Name: "plugins.app:test-app:reader", - DisplayName: "test-app reader", + Name: "Reader", Description: "View everything in the test-app plugin", Permissions: []plugins.Permission{ {Action: "plugins.app:access", Scope: "plugins.app:id:test-app"}, diff --git a/pkg/plugins/manager/testdata/test-app-with-roles/MANIFEST.txt b/pkg/plugins/manager/testdata/test-app-with-roles/MANIFEST.txt index b268131043f..c61f5d53ead 100644 --- a/pkg/plugins/manager/testdata/test-app-with-roles/MANIFEST.txt +++ b/pkg/plugins/manager/testdata/test-app-with-roles/MANIFEST.txt @@ -12,20 +12,20 @@ Hash: SHA512 ], "plugin": "test-app", "version": "1.0.0", - "time": 1666953431573, + "time": 1667484928676, "keyId": "7e4d0c6a708866e7", "files": { - "plugin.json": "8017d19868809409e54e70eab116366de263005aa70960d44a12dc4dc5582cee" + "plugin.json": "3348335ec100392b325f3eeb882a07c729e9cbf0f1ae331239f46840bb1a01eb" } } -----BEGIN PGP SIGNATURE----- Version: OpenPGP.js v4.10.10 Comment: https://openpgpjs.org -wrgEARMKAAYFAmNbsNcAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq -cIhm5z2+AgYqtKZ4tU/VBo8kOI49LfV85JKunAxPOvfaU3pRseRnWSyRBS0X -pKI2ekKebOSRZIs+zDPA0qTl1ihOY9bKe52pwwIJAf1IDq1P7G861dFilTuF -jCHQq6aS3NGy5o1N480Xof8PZdrI/xYDqSoy2F+688FR76ShyAM4B00Skt7c -9YSCsLx+ -=cVti +wrgEARMKAAYFAmNjzQAAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq +cIhm509bAgiY3ZHrA6i95x6vef1z2cS6Q6+zzeLrfZ31AFtxq2Y/OYIQKBZC +BZIp9LufCLCEDnwp+ocMGtDQV7yk1vUKM/zz/QIJAYs8d8pVnao31eqUB5Hy +8WdkLFYa3V6rx1Da3iM24A5JgJwpTgudVYRQFRH6XR/HZt/EBRckAeQPxsN6 +qodkjllo +=TMGo -----END PGP SIGNATURE----- diff --git a/pkg/plugins/manager/testdata/test-app-with-roles/plugin.json b/pkg/plugins/manager/testdata/test-app-with-roles/plugin.json index dcdf2eb20ae..d579fb26bb7 100644 --- a/pkg/plugins/manager/testdata/test-app-with-roles/plugin.json +++ b/pkg/plugins/manager/testdata/test-app-with-roles/plugin.json @@ -17,8 +17,7 @@ "roles": [ { "role": { - "name": "plugins.app:test-app:reader", - "displayName": "test-app reader", + "name": "Reader", "description": "View everything in the test-app plugin", "permissions": [ { diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index f4589b3f9d6..674156de1ad 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -279,7 +279,6 @@ type RoleRegistration struct { // Role is the model for Role in RBAC. type Role struct { Name string `json:"name"` - DisplayName string `json:"displayName"` Description string `json:"description"` Permissions []Permission `json:"permissions"` } diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 461087155ec..9bab820f8eb 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -220,7 +220,7 @@ func (s *Service) DeclarePluginRoles(_ context.Context, ID, name string, regs [] return nil } - acRegs := pluginutils.ToRegistrations(name, regs) + acRegs := pluginutils.ToRegistrations(ID, name, regs) for _, r := range acRegs { if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil { return err diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 88662e6ea40..c635d506e91 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -175,31 +175,19 @@ func TestService_DeclarePluginRoles(t *testing.T) { pluginID: "test-app", registrations: []plugins.RoleRegistration{ { - Role: plugins.Role{Name: "plugins:test-app:test"}, + Role: plugins.Role{Name: "Tester"}, Grants: []string{"Admin"}, }, }, wantErr: false, }, - { - name: "should fail registration invalid role name", - pluginID: "test-app", - registrations: []plugins.RoleRegistration{ - { - Role: plugins.Role{Name: "invalid.plugins:test-app:test"}, - Grants: []string{"Admin"}, - }, - }, - wantErr: true, - err: &accesscontrol.ErrorInvalidRole{}, - }, { name: "should add registration with valid permissions", pluginID: "test-app", registrations: []plugins.RoleRegistration{ { Role: plugins.Role{ - Name: "plugins:test-app:test", + Name: "Tester", Permissions: []plugins.Permission{ {Action: "plugins.app:access"}, {Action: "test-app:read"}, @@ -217,7 +205,7 @@ func TestService_DeclarePluginRoles(t *testing.T) { registrations: []plugins.RoleRegistration{ { Role: plugins.Role{ - Name: "plugins:test-app:test", + Name: "Tester", Permissions: []plugins.Permission{ {Action: "invalid.test-app.resource:read"}, }, @@ -233,7 +221,7 @@ func TestService_DeclarePluginRoles(t *testing.T) { pluginID: "test-app", registrations: []plugins.RoleRegistration{ { - Role: plugins.Role{Name: "plugins:test-app:test"}, + Role: plugins.Role{Name: "Tester"}, Grants: []string{"WrongAdmin"}, }, }, @@ -245,11 +233,11 @@ func TestService_DeclarePluginRoles(t *testing.T) { pluginID: "test-app", registrations: []plugins.RoleRegistration{ { - Role: plugins.Role{Name: "plugins:test-app:test"}, + Role: plugins.Role{Name: "Tester"}, Grants: []string{"Admin"}, }, { - Role: plugins.Role{Name: "plugins:test-app:test2"}, + Role: plugins.Role{Name: "Tester2"}, Grants: []string{"Admin"}, }, }, @@ -335,7 +323,8 @@ func TestService_RegisterFixedRoles(t *testing.T) { registrations: []accesscontrol.RoleRegistration{ { Role: accesscontrol.RoleDTO{ - Name: "plugins:test-app:test", + Name: accesscontrol.PluginRolePrefix + "test-app:tester", + DisplayName: "Tester", Permissions: []accesscontrol.Permission{{Action: "test-app:test"}}, }, Grants: []string{"Editor"}, diff --git a/pkg/services/accesscontrol/pluginutils/utils.go b/pkg/services/accesscontrol/pluginutils/utils.go index 04d61e7f010..eac5effdcf0 100644 --- a/pkg/services/accesscontrol/pluginutils/utils.go +++ b/pkg/services/accesscontrol/pluginutils/utils.go @@ -1,6 +1,7 @@ package pluginutils import ( + "fmt" "strings" "github.com/grafana/grafana/pkg/plugins" @@ -34,14 +35,14 @@ func ValidatePluginRole(pluginID string, role ac.RoleDTO) error { return ValidatePluginPermissions(pluginID, role.Permissions) } -func ToRegistrations(pluginName string, regs []plugins.RoleRegistration) []ac.RoleRegistration { +func ToRegistrations(pluginID, pluginName string, regs []plugins.RoleRegistration) []ac.RoleRegistration { res := make([]ac.RoleRegistration, 0, len(regs)) for i := range regs { res = append(res, ac.RoleRegistration{ Role: ac.RoleDTO{ Version: 1, - Name: regs[i].Role.Name, - DisplayName: regs[i].Role.DisplayName, + Name: roleName(pluginID, regs[i].Role.Name), + DisplayName: regs[i].Role.Name, Description: regs[i].Role.Description, Group: pluginName, Permissions: toPermissions(regs[i].Role.Permissions), @@ -53,6 +54,10 @@ func ToRegistrations(pluginName string, regs []plugins.RoleRegistration) []ac.Ro return res } +func roleName(pluginID, roleName string) string { + return fmt.Sprintf("%v%v:%v", ac.PluginRolePrefix, pluginID, strings.Replace(strings.ToLower(roleName), " ", "-", -1)) +} + func toPermissions(perms []plugins.Permission) []ac.Permission { res := make([]ac.Permission, 0, len(perms)) for i := range perms { diff --git a/pkg/services/accesscontrol/pluginutils/utils_test.go b/pkg/services/accesscontrol/pluginutils/utils_test.go index d36f041585b..8722c8e747a 100644 --- a/pkg/services/accesscontrol/pluginutils/utils_test.go +++ b/pkg/services/accesscontrol/pluginutils/utils_test.go @@ -24,8 +24,7 @@ func TestToRegistrations(t *testing.T) { regs: []plugins.RoleRegistration{ { Role: plugins.Role{ - Name: "test:name", - DisplayName: "Test", + Name: "Tester", Description: "Test", Permissions: []plugins.Permission{ {Action: "test:action"}, @@ -36,7 +35,7 @@ func TestToRegistrations(t *testing.T) { }, { Role: plugins.Role{ - Name: "test:name", + Name: "Admin Validator", Permissions: []plugins.Permission{}, }, }, @@ -45,10 +44,10 @@ func TestToRegistrations(t *testing.T) { { Role: ac.RoleDTO{ Version: 1, - Name: "test:name", - DisplayName: "Test", + Name: ac.PluginRolePrefix + "plugin-id:tester", + DisplayName: "Tester", Description: "Test", - Group: "PluginName", + Group: "Plugin Name", Permissions: []ac.Permission{ {Action: "test:action"}, {Action: "test:action", Scope: "test:scope"}, @@ -60,8 +59,9 @@ func TestToRegistrations(t *testing.T) { { Role: ac.RoleDTO{ Version: 1, - Name: "test:name", - Group: "PluginName", + Name: ac.PluginRolePrefix + "plugin-id:admin-validator", + DisplayName: "Admin Validator", + Group: "Plugin Name", Permissions: []ac.Permission{}, OrgID: ac.GlobalOrgID, }, @@ -71,7 +71,7 @@ func TestToRegistrations(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ToRegistrations("PluginName", tt.regs) + got := ToRegistrations("plugin-id", "Plugin Name", tt.regs) require.Equal(t, tt.want, got) }) } From 98dbc637ccfe08b49e878ff5e6bcb10a9cafd5d4 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Tue, 15 Nov 2022 10:50:37 +0100 Subject: [PATCH 238/926] Auth: Always include oauth and saml settings for frontend (#58705) * Auth: Always include oauth and saml settings --- pkg/api/frontendsettings.go | 14 ++++++++++++++ pkg/api/frontendsettings_test.go | 2 ++ pkg/api/login.go | 13 ------------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index adb51d4cf15..855a9564243 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -198,6 +198,9 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "unifiedAlerting": map[string]interface{}{ "minInterval": hs.Cfg.UnifiedAlerting.MinInterval.String(), }, + "oauth": hs.getEnabledOAuthProviders(), + "samlEnabled": hs.samlEnabled(), + "samlName": hs.samlName(), } if hs.ThumbService != nil { @@ -501,3 +504,14 @@ func (hs *HTTPServer) pluginSettings(ctx context.Context, orgID int64) (map[stri return pluginSettings, nil } + +func (hs *HTTPServer) getEnabledOAuthProviders() map[string]interface{} { + providers := make(map[string]interface{}) + for key, oauth := range hs.SocialService.GetOAuthInfoProviders() { + providers[key] = map[string]string{ + "name": oauth.Name, + "icon": oauth.Icon, + } + } + return providers +} diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 45db1840bb9..ef37a076523 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/grafana/grafana/pkg/login/social" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -57,6 +58,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. grafanaUpdateChecker: &updatechecker.GrafanaService{}, AccessControl: accesscontrolmock.New().WithDisabled(), PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), + SocialService: social.ProvideService(cfg), } m := web.New() diff --git a/pkg/api/login.go b/pkg/api/login.go index 559d34ca801..20baa31bde5 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -91,19 +91,6 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { return } - enabledOAuths := make(map[string]interface{}) - providers := hs.SocialService.GetOAuthInfoProviders() - for key, oauth := range providers { - enabledOAuths[key] = map[string]string{ - "name": oauth.Name, - "icon": oauth.Icon, - } - } - - viewData.Settings["oauth"] = enabledOAuths - viewData.Settings["samlEnabled"] = hs.samlEnabled() - viewData.Settings["samlName"] = hs.samlName() - if loginError, ok := hs.tryGetEncryptedCookie(c, loginErrorCookieName); ok { // this cookie is only set whenever an OAuth login fails // therefore the loginError should be passed to the view data From 93b4b9154e3db852dd23a5aa67e24f60f5fca827 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Tue, 15 Nov 2022 12:58:12 +0200 Subject: [PATCH 239/926] Chore: Restore folder properties (#58743) * Chore: Fix folder URL * Restore more folder properties * Fixup --- pkg/api/folder.go | 42 +++++++++++++++++------------------- pkg/services/folder/model.go | 30 +++++++++++++------------- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 280fbb5a804..4373bb2d54f 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -227,30 +227,28 @@ func (hs *HTTPServer) newToFolderDto(c *models.ReqContext, g guardian.DashboardG // Finding creator and last updater of the folder updater, creator := anonString, anonString - /* - if folder.CreatedBy > 0 { - creator = hs.getUserLogin(c.Req.Context(), folder.CreatedBy) - } - if folder.UpdatedBy > 0 { - updater = hs.getUserLogin(c.Req.Context(), folder.UpdatedBy) - } - */ + if folder.CreatedBy > 0 { + creator = hs.getUserLogin(c.Req.Context(), folder.CreatedBy) + } + if folder.UpdatedBy > 0 { + updater = hs.getUserLogin(c.Req.Context(), folder.UpdatedBy) + } return dtos.Folder{ - Id: folder.ID, - Uid: folder.UID, - Title: folder.Title, - //Url: folder.Url, - //HasACL: folder.HasACL, - CanSave: canSave, - CanEdit: canEdit, - CanAdmin: canAdmin, - CanDelete: canDelete, - CreatedBy: creator, - Created: folder.Created, - UpdatedBy: updater, - Updated: folder.Updated, - //Version: folder.Version, + Id: folder.ID, + Uid: folder.UID, + Title: folder.Title, + Url: folder.Url, + HasACL: folder.HasACL, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CanDelete: canDelete, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + Version: folder.Version, AccessControl: hs.getAccessControlMetadata(c, c.OrgID, dashboards.ScopeFoldersPrefix, folder.UID), } } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index fe18991393a..ea736371ef5 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -34,11 +34,11 @@ type Folder struct { // TODO: validate if this field is required/relevant to folders. // currently there is no such column - // Version int - // Url string - // UpdatedBy int64 - // CreatedBy int64 - // HasACL bool + Version int + Url string + UpdatedBy int64 + CreatedBy int64 + HasACL bool } type FolderDTO struct { @@ -142,15 +142,15 @@ func (f *Folder) ToLegacyModel() *models.Folder { func FromDashboard(dash *models.Dashboard) *Folder { return &Folder{ - ID: dash.Id, - UID: dash.Uid, - Title: dash.Title, - //HasACL: dash.HasACL, - //Url: dash.GetUrl(), - //Version: dash.Version, - Created: dash.Created, - //CreatedBy: dash.CreatedBy, - Updated: dash.Updated, - //UpdatedBy: dash.UpdatedBy, + ID: dash.Id, + UID: dash.Uid, + Title: dash.Title, + HasACL: dash.HasACL, + Url: models.GetFolderUrl(dash.Uid, dash.Slug), + Version: dash.Version, + Created: dash.Created, + CreatedBy: dash.CreatedBy, + Updated: dash.Updated, + UpdatedBy: dash.UpdatedBy, } } From 84a69135a727d6175606ace9a2ac952e3338f3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 15 Nov 2022 12:54:24 +0100 Subject: [PATCH 240/926] Scene: Variables and support for declaring variable dependencies and getting notified or re-rendered when they change (#58299) * Component that can cache and extract variable dependencies * Component that can cache and extract variable dependencies * Updates * Refactoring * Lots of refactoring and iterations of supporting both re-rendering and query re-execution * Updated SceneCanvasText * Updated name of file * Updated * Refactoring a bit * Added back getName * Added comment * minor fix * Minor fix * Merge fixes * Merge fixes * Some review fixes * Updated comment * Added forceRender function * Add back fail on console log --- .betterer.results | 16 ++- .../provisioning/quota_checker_mock.go | 6 +- .../scenes/components/SceneCanvasText.tsx | 13 +- .../features/scenes/components/VizPanel.tsx | 14 +- .../scenes/core/SceneComponentWrapper.tsx | 4 + .../features/scenes/core/SceneObjectBase.tsx | 82 ++++++----- public/app/features/scenes/core/types.ts | 11 +- public/app/features/scenes/core/utils.ts | 56 ++++++++ .../scenes/querying/SceneQueryRunner.ts | 6 + public/app/features/scenes/scenes/demo.tsx | 33 +---- public/app/features/scenes/scenes/queries.ts | 5 +- .../features/scenes/scenes/variablesDemo.tsx | 37 +++-- .../VariableDependencyConfig.test.ts | 86 ++++++++++++ .../variables/VariableDependencyConfig.ts | 130 ++++++++++++++++++ .../components/VariableValueSelectors.tsx | 16 +-- .../variables/sceneTemplateInterpolator.ts | 5 + ...eSet.test.ts => SceneVariableSet.test.tsx} | 42 ++++++ .../scenes/variables/sets/SceneVariableSet.ts | 91 +++++++----- public/app/features/scenes/variables/types.ts | 21 ++- .../variables/variants/TestVariable.tsx | 11 +- .../app/plugins/datasource/testdata/types.ts | 1 + 21 files changed, 539 insertions(+), 147 deletions(-) create mode 100644 public/app/features/scenes/core/utils.ts create mode 100644 public/app/features/scenes/variables/VariableDependencyConfig.test.ts create mode 100644 public/app/features/scenes/variables/VariableDependencyConfig.ts rename public/app/features/scenes/variables/sets/{SceneVariableSet.test.ts => SceneVariableSet.test.tsx} (66%) diff --git a/.betterer.results b/.betterer.results index face19f7e0f..fe3efee0f46 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4570,10 +4570,7 @@ 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, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"] + [0, 0, 0, "Do not use any type assertions.", "3"] ], "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -4582,6 +4579,11 @@ exports[`better eslint`] = { "public/app/features/scenes/core/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/scenes/core/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "public/app/features/scenes/editor/SceneObjectTree.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -4593,6 +4595,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"] ], + "public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], "public/app/features/scenes/variables/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/pkg/services/ngalert/provisioning/quota_checker_mock.go b/pkg/services/ngalert/provisioning/quota_checker_mock.go index 1dac163c33d..fcdfbfdbaa0 100644 --- a/pkg/services/ngalert/provisioning/quota_checker_mock.go +++ b/pkg/services/ngalert/provisioning/quota_checker_mock.go @@ -49,9 +49,9 @@ type MockQuotaChecker_CheckQuotaReached_Call struct { } // CheckQuotaReached is a helper method to define mock.On call -// - ctx context.Context -// - target quota.TargetSrv -// - scopeParams *quota.ScopeParameters +// - ctx context.Context +// - target quota.TargetSrv +// - scopeParams *quota.ScopeParameters func (_e *MockQuotaChecker_Expecter) CheckQuotaReached(ctx interface{}, target interface{}, scopeParams interface{}) *MockQuotaChecker_CheckQuotaReached_Call { return &MockQuotaChecker_CheckQuotaReached_Call{Call: _e.mock.On("CheckQuotaReached", ctx, target, scopeParams)} } diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx index 93b5e9a74a8..b76e1e748d4 100644 --- a/public/app/features/scenes/components/SceneCanvasText.tsx +++ b/public/app/features/scenes/components/SceneCanvasText.tsx @@ -4,7 +4,7 @@ import { Field, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; -import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator'; +import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; export interface SceneCanvasTextState extends SceneLayoutChildState { text: string; @@ -15,9 +15,10 @@ export interface SceneCanvasTextState extends SceneLayoutChildState { export class SceneCanvasText extends SceneObjectBase { public static Editor = Editor; + protected _variableDependency = new VariableDependencyConfig(this, { statePaths: ['text'] }); + public static Component = ({ model }: SceneComponentProps) => { - const { text, fontSize = 20, align = 'left' } = model.useState(); - const textInterpolated = sceneTemplateInterpolator(text, model); + const { text, fontSize = 20, align = 'left', key } = model.useState(); const style: CSSProperties = { fontSize: fontSize, @@ -28,7 +29,11 @@ export class SceneCanvasText extends SceneObjectBase { justifyContent: align, }; - return
{textInterpolated}
; + return ( +
+ {model.interpolate(text)} +
+ ); }; } diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx index 375d4950c92..3882a898552 100644 --- a/public/app/features/scenes/components/VizPanel.tsx +++ b/public/app/features/scenes/components/VizPanel.tsx @@ -7,6 +7,7 @@ import { Field, PanelChrome, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; +import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; import { SceneDragHandle } from './SceneDragHandle'; @@ -21,6 +22,10 @@ export class VizPanel extends SceneObjectBase { public static Component = ScenePanelRenderer; public static Editor = VizPanelEditor; + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['title'], + }); + public onSetTimeRange = (timeRange: AbsoluteTimeRange) => { const sceneTimeRange = this.getTimeRange(); sceneTimeRange.setState({ @@ -41,6 +46,8 @@ function ScenePanelRenderer({ model }: SceneComponentProps) { const isDraggable = layout.state.isDraggable ? state.isDraggable : false; const dragHandle = ; + const titleInterpolated = model.interpolate(title); + return ( {({ width, height }) => { @@ -49,7 +56,12 @@ function ScenePanelRenderer({ model }: SceneComponentProps) { } return ( - + {(innerWidth, innerHeight) => ( <> ({ }; }, [model]); + /** Useful for tests and evaluating efficiency in reducing renderings */ + // @ts-ignore + model._renderCount += 1; + if (!isEditing) { return inner; } diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index 7c25982416e..9ba3bfa1b0d 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -5,6 +5,9 @@ import { v4 as uuidv4 } from 'uuid'; import { BusEvent, BusEventHandler, BusEventType, EventBusSrv } from '@grafana/data'; import { useForceUpdate } from '@grafana/ui'; +import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator'; +import { SceneVariables, SceneVariableDependencyConfigLike } from '../variables/types'; + import { SceneComponentWrapper } from './SceneComponentWrapper'; import { SceneObjectStateChangedEvent } from './events'; import { @@ -16,6 +19,7 @@ import { SceneObjectState, SceneLayoutState, } from './types'; +import { cloneSceneObject, forEachSceneObjectInState } from './utils'; export abstract class SceneObjectBase implements SceneObject @@ -25,9 +29,13 @@ export abstract class SceneObjectBase (child._parent = this)); } /** @@ -104,6 +105,7 @@ export abstract class SceneObjectBase): this { - const clonedState = { ...this.state }; + return cloneSceneObject(this, withState); + } - // Clone any SceneItems in state - for (const key in clonedState) { - const propValue = clonedState[key]; - if (propValue instanceof SceneObjectBase) { - clonedState[key] = propValue.clone(); - } - - // Clone scene objects in arrays - if (Array.isArray(propValue)) { - const newArray: any = []; - for (const child of propValue) { - if (child instanceof SceneObjectBase) { - newArray.push(child.clone()); - } else { - newArray.push(child); - } - } - clonedState[key] = newArray; - } + /** + * Interpolates the given string using the current scene object as context. + * TODO: Cache interpolatinos? + */ + public interpolate(value: string | undefined) { + // Skip interpolation if there are no variable depdendencies + if (!value || !this._variableDependency || this._variableDependency.getNames().size === 0) { + return value; } - Object.assign(clonedState, withState); - - return new (this.constructor as any)(clonedState); + return sceneTemplateInterpolator(value, this); } } diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index 86e678184b4..f99d764c064 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -3,7 +3,7 @@ import { Observer, Subscription, Unsubscribable } from 'rxjs'; import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, UrlQueryMap } from '@grafana/data'; -import { SceneVariables } from '../variables/types'; +import { SceneVariableDependencyConfigLike, SceneVariables } from '../variables/types'; export interface SceneObjectStatePlain { key?: string; @@ -62,6 +62,9 @@ export interface SceneObject /** SceneObject parent */ readonly parent?: SceneObject; + /** This abtractions declares what variables the scene object depends on and how to handle when they change value. **/ + readonly variableDependency?: SceneVariableDependencyConfigLike; + /** Subscribe to state changes */ subscribeToState(observer?: Partial>): Subscription; @@ -92,6 +95,9 @@ export interface SceneObject /** Get the closest node with data */ getData(): SceneObject; + /** Get the closest node with variables */ + getVariables(): SceneVariables | undefined; + /** Get the closest node with time range */ getTimeRange(): SceneTimeRange; @@ -106,6 +112,9 @@ export interface SceneObject /** To be replaced by declarative method */ Editor(props: SceneComponentProps>): React.ReactElement | null; + + /** Force a re-render, should only be needed when variable values change */ + forceRender(): void; } export type SceneLayoutChild = SceneObject; diff --git a/public/app/features/scenes/core/utils.ts b/public/app/features/scenes/core/utils.ts new file mode 100644 index 00000000000..c2474615bf7 --- /dev/null +++ b/public/app/features/scenes/core/utils.ts @@ -0,0 +1,56 @@ +import { SceneObjectBase } from './SceneObjectBase'; +import { SceneObjectState, SceneObjectStatePlain } from './types'; + +/** + * Will call callback for all first level child scene objects and scene objects inside arrays + */ +export function forEachSceneObjectInState(state: SceneObjectStatePlain, callback: (scene: SceneObjectBase) => void) { + for (const propValue of Object.values(state)) { + if (propValue instanceof SceneObjectBase) { + callback(propValue); + } + + if (Array.isArray(propValue)) { + for (const child of propValue) { + if (child instanceof SceneObjectBase) { + callback(child); + } + } + } + } +} + +/** + * Will create new SceneItem with shalled cloned state, but all states items of type SceneObject are deep cloned + */ +export function cloneSceneObject, TState extends SceneObjectState>( + sceneObject: SceneObjectBase, + withState?: Partial +): T { + const clonedState = { ...sceneObject.state }; + + // Clone any SceneItems in state + for (const key in clonedState) { + const propValue = clonedState[key]; + if (propValue instanceof SceneObjectBase) { + clonedState[key] = propValue.clone(); + } + + // Clone scene objects in arrays + if (Array.isArray(propValue)) { + const newArray: any = []; + for (const child of propValue) { + if (child instanceof SceneObjectBase) { + newArray.push(child.clone()); + } else { + newArray.push(child); + } + } + clonedState[key] = newArray; + } + } + + Object.assign(clonedState, withState); + + return new (sceneObject.constructor as any)(clonedState); +} diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index fed9e351f59..01bf47e1fc0 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -18,6 +18,7 @@ import { runRequest } from 'app/features/query/state/runRequest'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneObjectStatePlain } from '../core/types'; +import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; export interface QueryRunnerState extends SceneObjectStatePlain { data?: PanelData; @@ -31,6 +32,11 @@ export interface DataQueryExtended extends DataQuery { export class SceneQueryRunner extends SceneObjectBase { private querySub?: Unsubscribable; + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['queries'], + onReferencedVariableValueChanged: () => this.runQueries(), + }); + public activate() { super.activate(); diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx index f6acf02703d..a4e96b97a70 100644 --- a/public/app/features/scenes/scenes/demo.tsx +++ b/public/app/features/scenes/scenes/demo.tsx @@ -9,7 +9,8 @@ import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getFlexLayoutTest(): Scene { const scene = new Scene({ @@ -51,18 +52,7 @@ export function getFlexLayoutTest(): Scene { }), $editor: new SceneEditManager({}), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); @@ -70,19 +60,10 @@ export function getFlexLayoutTest(): Scene { } export function getScenePanelRepeaterTest(): Scene { - const queryRunner = new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - seriesCount: 2, - alias: '__server_names', - scenarioId: 'random_walk', - }, - ], + const queryRunner = getQueryRunnerWithRandomWalkQuery({ + seriesCount: 2, + alias: '__server_names', + scenarioId: 'random_walk', }); const scene = new Scene({ diff --git a/public/app/features/scenes/scenes/queries.ts b/public/app/features/scenes/scenes/queries.ts index 82a60eae3fe..66f411c8b74 100644 --- a/public/app/features/scenes/scenes/queries.ts +++ b/public/app/features/scenes/scenes/queries.ts @@ -1,6 +1,8 @@ +import { TestDataQuery } from 'app/plugins/datasource/testdata/types'; + import { SceneQueryRunner } from '../querying/SceneQueryRunner'; -export function getQueryRunnerWithRandomWalkQuery() { +export function getQueryRunnerWithRandomWalkQuery(overrides?: Partial) { return new SceneQueryRunner({ queries: [ { @@ -10,6 +12,7 @@ export function getQueryRunnerWithRandomWalkQuery() { type: 'testdata', }, scenarioId: 'random_walk', + ...overrides, }, ], }); diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx index c6b4f1da6f3..1f2e8d93663 100644 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -4,25 +4,18 @@ import { Scene } from '../components/Scene'; import { SceneCanvasText } from '../components/SceneCanvasText'; import { SceneSubMenu } from '../components/SceneSubMenu'; import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { VariableValueSelectors } from '../variables/components/VariableValueSelectors'; import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; import { TestVariable } from '../variables/variants/TestVariable'; +import { getQueryRunnerWithRandomWalkQuery } from './queries'; + export function getVariablesDemo(): Scene { const scene = new Scene({ title: 'Variables', - layout: new SceneFlexLayout({ - direction: 'row', - children: [ - new SceneCanvasText({ - text: 'Some text with a variable: ${server} - ${pod}', - fontSize: 40, - align: 'center', - }), - ], - }), $variables: new SceneVariableSet({ variables: [ new TestVariable({ @@ -46,12 +39,34 @@ export function getVariablesDemo(): Scene { query: 'A.$server.$pod.*', value: 'handler', delayMs: 1000, - isMulti: true, + //isMulti: true, text: '', options: [], }), ], }), + layout: new SceneFlexLayout({ + direction: 'row', + children: [ + new SceneFlexLayout({ + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'handler: $handler', + $data: getQueryRunnerWithRandomWalkQuery({ + alias: 'handler: $handler', + }), + }), + new SceneCanvasText({ + size: { width: '40%' }, + text: 'server - pod: ${server} - ${pod}', + fontSize: 20, + align: 'center', + }), + ], + }), + ], + }), $timeRange: new SceneTimeRange(getDefaultTimeRange()), actions: [new SceneTimePicker({})], subMenu: new SceneSubMenu({ diff --git a/public/app/features/scenes/variables/VariableDependencyConfig.test.ts b/public/app/features/scenes/variables/VariableDependencyConfig.test.ts new file mode 100644 index 00000000000..3e14a66dd09 --- /dev/null +++ b/public/app/features/scenes/variables/VariableDependencyConfig.test.ts @@ -0,0 +1,86 @@ +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneObjectStatePlain } from '../core/types'; + +import { VariableDependencyConfig } from './VariableDependencyConfig'; +import { ConstantVariable } from './variants/ConstantVariable'; + +interface TestState extends SceneObjectStatePlain { + query: string; + otherProp: string; + nested: { + query: string; + }; +} + +class TestObj extends SceneObjectBase { + public constructor() { + super({ + query: 'query with ${queryVarA} ${queryVarB}', + otherProp: 'string with ${otherPropA}', + nested: { + query: 'nested object with ${nestedVarA}', + }, + }); + } +} + +describe('VariableDependencySet', () => { + it('Should be able to extract dependencies from all state', () => { + const sceneObj = new TestObj(); + const deps = new VariableDependencyConfig(sceneObj, {}); + + expect(deps.getNames()).toEqual(new Set(['queryVarA', 'queryVarB', 'nestedVarA', 'otherPropA'])); + }); + + it('Should be able to extract dependencies from statePaths', () => { + const sceneObj = new TestObj(); + const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] }); + + expect(deps.getNames()).toEqual(new Set(['queryVarA', 'queryVarB', 'nestedVarA'])); + expect(deps.hasDependencyOn('queryVarA')).toBe(true); + }); + + it('Should cache variable extraction', () => { + const sceneObj = new TestObj(); + const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] }); + + deps.getNames(); + deps.getNames(); + + expect(deps.scanCount).toBe(1); + }); + + it('Should not rescan if state changes but not any of the state paths to scan', () => { + const sceneObj = new TestObj(); + const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] }); + deps.getNames(); + + sceneObj.setState({ otherProp: 'new value' }); + + deps.getNames(); + expect(deps.scanCount).toBe(1); + }); + + it('Should re-scan when both state and specific state path change', () => { + const sceneObj = new TestObj(); + const deps = new VariableDependencyConfig(sceneObj, { statePaths: ['query', 'nested'] }); + deps.getNames(); + + sceneObj.setState({ query: 'new query with ${newVar}' }); + + expect(deps.getNames()).toEqual(new Set(['newVar', 'nestedVarA'])); + expect(deps.scanCount).toBe(2); + }); + + it('variableValuesChanged should only call onReferencedVariableValueChanged if dependent variable has changed', () => { + const sceneObj = new TestObj(); + const fn = jest.fn(); + const deps = new VariableDependencyConfig(sceneObj, { onReferencedVariableValueChanged: fn }); + + deps.variableValuesChanged(new Set([new ConstantVariable({ name: 'not-dep', value: '1' })])); + expect(fn.mock.calls.length).toBe(0); + + deps.variableValuesChanged(new Set([new ConstantVariable({ name: 'queryVarA', value: '1' })])); + expect(fn.mock.calls.length).toBe(1); + }); +}); diff --git a/public/app/features/scenes/variables/VariableDependencyConfig.ts b/public/app/features/scenes/variables/VariableDependencyConfig.ts new file mode 100644 index 00000000000..c07d3e38327 --- /dev/null +++ b/public/app/features/scenes/variables/VariableDependencyConfig.ts @@ -0,0 +1,130 @@ +import { variableRegex } from 'app/features/variables/utils'; + +import { SceneObject, SceneObjectState } from '../core/types'; + +import { SceneVariable, SceneVariableDependencyConfigLike } from './types'; + +interface VariableDependencyConfigOptions { + /** + * State paths to scan / extract variable dependencies from. Leave empty to scan all paths. + */ + statePaths?: Array; + /** + * Optional way to customize how to handle when a dependent variable changes + * If not specified the default behavior is to trigger a re-render + */ + onReferencedVariableValueChanged?: () => void; +} + +export class VariableDependencyConfig implements SceneVariableDependencyConfigLike { + private _state: TState | undefined; + private _dependencies = new Set(); + private _statePaths?: Array; + private _onReferencedVariableValueChanged: () => void; + + public scanCount = 0; + + public constructor(private _sceneObject: SceneObject, options: VariableDependencyConfigOptions) { + this._statePaths = options.statePaths; + this._onReferencedVariableValueChanged = + options.onReferencedVariableValueChanged ?? this.defaultHandlerReferencedVariableValueChanged; + } + + /** + * Used to check for dependency on a specific variable + */ + public hasDependencyOn(name: string): boolean { + return this.getNames().has(name); + } + + /** + * This is called whenever any set of variables have new values. It up to this implementation to check if it's relevant given the current dependencies. + */ + public variableValuesChanged(variables: Set) { + const deps = this.getNames(); + + for (const variable of variables) { + if (deps.has(variable.state.name)) { + this._onReferencedVariableValueChanged(); + return; + } + } + } + + /** + * Only way to force a re-render is to update state right now + */ + private defaultHandlerReferencedVariableValueChanged = () => { + this._sceneObject.forceRender(); + }; + + public getNames(): Set { + const prevState = this._state; + const newState = (this._state = this._sceneObject.state); + + if (!prevState) { + // First time we always scan for dependencies + this.scanStateForDependencies(this._state); + return this._dependencies; + } + + // Second time we only scan if state is a different and if any specific state path has changed + if (newState !== prevState) { + if (this._statePaths) { + for (const path of this._statePaths) { + if (newState[path] !== prevState[path]) { + this.scanStateForDependencies(newState); + break; + } + } + } else { + this.scanStateForDependencies(newState); + } + } + + return this._dependencies; + } + + private scanStateForDependencies(state: TState) { + this._dependencies.clear(); + this.scanCount += 1; + + if (this._statePaths) { + for (const path of this._statePaths) { + const value = state[path]; + if (value) { + this.extractVariablesFrom(value); + } + } + } else { + this.extractVariablesFrom(state); + } + } + + private extractVariablesFrom(value: unknown) { + variableRegex.lastIndex = 0; + + const stringToCheck = typeof value !== 'string' ? safeStringifyValue(value) : value; + + const matches = stringToCheck.matchAll(variableRegex); + if (!matches) { + return; + } + + for (const match of matches) { + const [, var1, var2, , var3] = match; + const variableName = var1 || var2 || var3; + this._dependencies.add(variableName); + } + } +} + +const safeStringifyValue = (value: unknown) => { + try { + return JSON.stringify(value, null); + } catch (error) { + console.error(error); + } + + return ''; +}; diff --git a/public/app/features/scenes/variables/components/VariableValueSelectors.tsx b/public/app/features/scenes/variables/components/VariableValueSelectors.tsx index 114cbe8e37c..2758d612ea5 100644 --- a/public/app/features/scenes/variables/components/VariableValueSelectors.tsx +++ b/public/app/features/scenes/variables/components/VariableValueSelectors.tsx @@ -6,14 +6,14 @@ import { Tooltip } from '@grafana/ui'; import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneComponentProps, SceneObject, SceneObjectStatePlain } from '../../core/types'; -import { SceneVariables, SceneVariableState } from '../types'; +import { SceneVariableState } from '../types'; export class VariableValueSelectors extends SceneObjectBase { public static Component = VariableValueSelectorsRenderer; } function VariableValueSelectorsRenderer({ model }: SceneComponentProps) { - const variables = getVariables(model).useState(); + const variables = model.getVariables()!.useState(); return ( <> @@ -24,18 +24,6 @@ function VariableValueSelectorsRenderer({ model }: SceneComponentProps }) { const state = variable.useState(); diff --git a/public/app/features/scenes/variables/sceneTemplateInterpolator.ts b/public/app/features/scenes/variables/sceneTemplateInterpolator.ts index 936c9030e40..716b95ed703 100644 --- a/public/app/features/scenes/variables/sceneTemplateInterpolator.ts +++ b/public/app/features/scenes/variables/sceneTemplateInterpolator.ts @@ -7,6 +7,11 @@ import { SceneObject } from '../core/types'; import { SceneVariable } from './types'; export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) { + // Skip any interpolation if there are no variables in the scene object graph + if (!sceneObject.getVariables()) { + return target; + } + variableRegex.lastIndex = 0; return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { diff --git a/public/app/features/scenes/variables/sets/SceneVariableSet.test.ts b/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx similarity index 66% rename from public/app/features/scenes/variables/sets/SceneVariableSet.test.ts rename to public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx index 1fa11457122..98954ecabf2 100644 --- a/public/app/features/scenes/variables/sets/SceneVariableSet.test.ts +++ b/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx @@ -1,3 +1,9 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { SceneCanvasText } from '../../components/SceneCanvasText'; +import { SceneFlexLayout } from '../../components/layout/SceneFlexLayout'; import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneObjectStatePlain } from '../../core/types'; import { TestVariable } from '../variants/TestVariable'; @@ -91,5 +97,41 @@ describe('SceneVariableList', () => { scene.deactivate(); expect(A.isGettingValues).toBe(false); }); + + describe('When update process completed and variables have changed values', () => { + it('Should trigger re-renders of dependent scene objects', async () => { + const A = new TestVariable({ name: 'A', query: 'A.*', value: '', text: '', options: [] }); + const B = new TestVariable({ name: 'B', query: 'A.$A.*', value: '', text: '', options: [] }); + + const helloText = new SceneCanvasText({ text: 'Hello' }); + const sceneObjectWithVariable = new SceneCanvasText({ text: '$A - $B' }); + + const scene = new SceneFlexLayout({ + $variables: new SceneVariableSet({ variables: [B, A] }), + children: [helloText, sceneObjectWithVariable], + }); + + render(); + + expect(screen.getByText('Hello')).toBeInTheDocument(); + + act(() => { + A.signalUpdateCompleted(); + B.signalUpdateCompleted(); + }); + + expect(screen.getByText('AA - AAA')).toBeInTheDocument(); + expect((helloText as any)._renderCount).toBe(1); + expect((sceneObjectWithVariable as any)._renderCount).toBe(2); + + act(() => { + B.onSingleValueChange({ value: 'B', text: 'B' }); + }); + + expect(screen.getByText('AA - B')).toBeInTheDocument(); + expect((helloText as any)._renderCount).toBe(1); + expect((sceneObjectWithVariable as any)._renderCount).toBe(3); + }); + }); }); }); diff --git a/public/app/features/scenes/variables/sets/SceneVariableSet.ts b/public/app/features/scenes/variables/sets/SceneVariableSet.ts index 8aee2ba3d57..58ad9bedf80 100644 --- a/public/app/features/scenes/variables/sets/SceneVariableSet.ts +++ b/public/app/features/scenes/variables/sets/SceneVariableSet.ts @@ -1,20 +1,19 @@ import { Unsubscribable } from 'rxjs'; import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { SceneObject } from '../../core/types'; +import { forEachSceneObjectInState } from '../../core/utils'; import { SceneVariable, SceneVariables, SceneVariableSetState, SceneVariableValueChangedEvent } from '../types'; export class SceneVariableSet extends SceneObjectBase implements SceneVariables { /** Variables that have changed in since the activation or since the first manual value change */ - // private variablesThatHaveChanged = new Map(); + private variablesThatHaveChanged = new Set(); /** Variables that are scheduled to be validated and updated */ - private variablesToUpdate = new Map(); - - /** Cached variable dependencies */ - private dependencies = new Map(); + private variablesToUpdate = new Set(); /** Variables currently updating */ - private updating = new Map(); + private updating = new Map(); public getByName(name: string): SceneVariable | undefined { // TODO: Replace with index @@ -50,7 +49,13 @@ export class SceneVariableSet extends SceneObjectBase imp * If one has a dependency that is currently in variablesToUpdate it will be skipped for now. */ private updateNextBatch() { - for (const [name, variable] of this.variablesToUpdate) { + // If we have nothing more to update and variable values changed we need to update scene objects that depend on these variables + if (this.variablesToUpdate.size === 0 && this.variablesThatHaveChanged.size > 0) { + this.notifyDependentSceneObjects(); + return; + } + + for (const variable of this.variablesToUpdate) { if (!variable.validateAndUpdate) { throw new Error('Variable added to variablesToUpdate but does not have validateAndUpdate'); } @@ -60,7 +65,7 @@ export class SceneVariableSet extends SceneObjectBase imp continue; } - this.updating.set(name, { + this.updating.set(variable, { variable, subscription: variable.validateAndUpdate().subscribe({ next: () => this.validateAndUpdateCompleted(variable), @@ -74,11 +79,11 @@ export class SceneVariableSet extends SceneObjectBase imp * A variable has completed it's update process. This could mean that variables that depend on it can now be updated in turn. */ private validateAndUpdateCompleted(variable: SceneVariable) { - const update = this.updating.get(variable.state.name); + const update = this.updating.get(variable); update?.subscription.unsubscribe(); - this.updating.delete(variable.state.name); - this.variablesToUpdate.delete(variable.state.name); + this.updating.delete(variable); + this.variablesToUpdate.delete(variable); this.updateNextBatch(); } @@ -94,15 +99,13 @@ export class SceneVariableSet extends SceneObjectBase imp * Checks if the variable has any dependencies that is currently in variablesToUpdate */ private hasDependendencyInUpdateQueue(variable: SceneVariable) { - const dependencies = this.dependencies.get(variable.state.name); + if (!variable.variableDependency) { + return false; + } - if (dependencies) { - for (const dep of dependencies) { - for (const otherVariable of this.variablesToUpdate.values()) { - if (otherVariable.state.name === dep) { - return true; - } - } + for (const otherVariable of this.variablesToUpdate.values()) { + if (variable.variableDependency?.hasDependencyOn(otherVariable.state.name)) { + return true; } } @@ -116,11 +119,7 @@ export class SceneVariableSet extends SceneObjectBase imp private validateAndUpdateAll() { for (const variable of this.state.variables) { if (variable.validateAndUpdate) { - this.variablesToUpdate.set(variable.state.name, variable); - } - - if (variable.getDependencies) { - this.dependencies.set(variable.state.name, variable.getDependencies()); + this.variablesToUpdate.add(variable); } } @@ -131,24 +130,54 @@ export class SceneVariableSet extends SceneObjectBase imp * This will trigger an update of all variables that depend on it. * */ private onVariableValueChanged = (event: SceneVariableValueChangedEvent) => { - const variable = event.payload; + const variableThatChanged = event.payload; + + this.variablesThatHaveChanged.add(variableThatChanged); // Ignore this change if it is currently updating - if (this.updating.has(variable.state.name)) { + if (this.updating.has(variableThatChanged)) { return; } - for (const [name, deps] of this.dependencies) { - if (deps.includes(variable.state.name)) { - const otherVariable = this.getByName(name); - if (otherVariable) { - this.variablesToUpdate.set(name, otherVariable); + // Add variables that depend on the changed variable to the update queue + for (const otherVariable of this.state.variables) { + if (otherVariable.variableDependency) { + if (otherVariable.variableDependency.hasDependencyOn(variableThatChanged.state.name)) { + this.variablesToUpdate.add(otherVariable); } } } this.updateNextBatch(); }; + + /** + * Walk scene object graph and update all objects that depend on variables that have changed + */ + private notifyDependentSceneObjects() { + if (!this.parent) { + return; + } + + this.traverseSceneAndNotify(this.parent); + this.variablesThatHaveChanged.clear(); + } + + /** + * Recursivly walk the full scene object graph and notify all objects with dependencies that include any of changed variables + */ + private traverseSceneAndNotify(sceneObject: SceneObject) { + // No need to notify variables under this SceneVariableSet + if (this === sceneObject) { + return; + } + + if (sceneObject.variableDependency) { + sceneObject.variableDependency.variableValuesChanged(this.variablesThatHaveChanged); + } + + forEachSceneObjectInState(sceneObject.state, (child) => this.traverseSceneAndNotify(child)); + } } export interface VariableUpdateInProgress { diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts index 3834c250561..5cb9dafa629 100644 --- a/public/app/features/scenes/variables/types.ts +++ b/public/app/features/scenes/variables/types.ts @@ -13,16 +13,9 @@ export interface SceneVariableState extends SceneObjectStatePlain { loading?: boolean; error?: any | null; description?: string | null; - //text: string | string[]; - //value: string | string[]; // old current.value } export interface SceneVariable extends SceneObject { - /** - * Should return a string array of other variables this variable is using in it's definition. - */ - getDependencies?(): string[]; - /** * This function is called on activation or when a dependency changes. */ @@ -60,3 +53,17 @@ export interface SceneVariables extends SceneObject { export class SceneVariableValueChangedEvent extends BusEventWithPayload { public static type = 'scene-variable-changed-value'; } + +export interface SceneVariableDependencyConfigLike { + /** Return all variable names this object depend on */ + getNames(): Set; + + /** Used to check for dependency on a specific variable */ + hasDependencyOn(name: string): boolean; + + /** + * Will be called when any variable value has changed, not just variable names returned by getNames(). + * It is up the implementation of this interface to filter it by actual dependencies. + **/ + variableValuesChanged(variables: Set): void; +} diff --git a/public/app/features/scenes/variables/variants/TestVariable.tsx b/public/app/features/scenes/variables/variants/TestVariable.tsx index c517861b3e5..bca7cdbc719 100644 --- a/public/app/features/scenes/variables/variants/TestVariable.tsx +++ b/public/app/features/scenes/variables/variants/TestVariable.tsx @@ -4,15 +4,14 @@ import { Observable, Subject } from 'rxjs'; import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree'; import { SceneComponentProps } from '../../core/types'; +import { VariableDependencyConfig } from '../VariableDependencyConfig'; import { VariableValueSelect } from '../components/VariableValueSelect'; -import { getVariableDependencies } from '../getVariableDependencies'; import { sceneTemplateInterpolator } from '../sceneTemplateInterpolator'; import { VariableValueOption } from '../types'; import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable'; export interface TestVariableState extends MultiValueVariableState { - //query: DataQuery; query: string; delayMs?: number; issuedQuery?: string; @@ -25,6 +24,10 @@ export class TestVariable extends MultiValueVariable { private completeUpdate = new Subject(); public isGettingValues = true; + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['query'], + }); + public getValueOptions(args: VariableGetOptionsArgs): Observable { const { delayMs } = this.state; @@ -69,10 +72,6 @@ export class TestVariable extends MultiValueVariable { this.completeUpdate.next(1); } - public getDependencies() { - return getVariableDependencies(this.state.query); - } - public static Component = ({ model }: SceneComponentProps) => { return ; }; diff --git a/public/app/plugins/datasource/testdata/types.ts b/public/app/plugins/datasource/testdata/types.ts index d3c521e4667..3e1d97ca488 100644 --- a/public/app/plugins/datasource/testdata/types.ts +++ b/public/app/plugins/datasource/testdata/types.ts @@ -24,6 +24,7 @@ export interface TestDataQuery extends DataQuery { csvFileName?: string; csvContent?: string; rawFrameContent?: string; + seriesCount?: number; usa?: USAQuery; errorType?: 'server_panic' | 'frontend_exception' | 'frontend_observable'; } From 028751a18a656edb5156fa5620c91585432a3988 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 15 Nov 2022 12:08:15 +0000 Subject: [PATCH 241/926] Navigation: Add quick actions button (#58707) * initial implementation for quick add * add new isCreateAction prop on NavModel * adjust separator margin * switch to primary button * undo changes to plugin.json * remove unused props from interface * use a consistent dropdown overlay type * memoize findCreateActions * add prop description * use a function so that menus are only rendered when the dropdown is open --- packages/grafana-data/src/types/navModel.ts | 2 + pkg/services/navtree/models.go | 1 + pkg/services/navtree/navtreeimpl/navtree.go | 16 ++-- .../AppChrome/NavToolbarSeparator.tsx | 9 ++- .../AppChrome/QuickAdd/QuickAdd.test.tsx | 73 ++++++++++++++++++ .../AppChrome/QuickAdd/QuickAdd.tsx | 76 +++++++++++++++++++ .../components/AppChrome/QuickAdd/utils.ts | 14 ++++ .../components/AppChrome/TopSearchBar.tsx | 11 +-- .../MegaMenu/NavBarMenuItemWrapper.tsx | 2 +- 9 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx create mode 100644 public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx create mode 100644 public/app/core/components/AppChrome/QuickAdd/utils.ts diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 33905084342..63e3e7050f0 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -28,6 +28,8 @@ export interface NavLinkDTO { emptyMessageId?: string; // The ID of the plugin that registered the page (in case it was registered by a plugin, otherwise left empty) pluginId?: string; + // Whether the page is used to create a new resource. We may place these in a different position in the UI. + isCreateAction?: boolean; } export interface NavModelItem extends NavLinkDTO { diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 00358200547..f71ceea0808 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -68,6 +68,7 @@ type NavLink struct { HighlightID string `json:"highlightId,omitempty"` EmptyMessageId string `json:"emptyMessageId,omitempty"` PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page) + IsCreateAction bool `json:"isCreateAction,omitempty"` } func (node *NavLink) Sort() { diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 94ed6c125b4..73d601b6ba3 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -408,13 +408,17 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) + } + if hasEditPerm { if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", ShowIconInNavbar: true, + Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", ShowIconInNavbar: true, IsCreateAction: true, }) } + } + if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) { if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "dashboards/folder/new", @@ -498,13 +502,15 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) fallbackHasEditPerm := func(*models.ReqContext) bool { return hasEditPerm } if hasAccess(fallbackHasEditPerm, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { - alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, - }) + if !s.features.IsEnabled(featuremgmt.FlagTopnav) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, + }) + } alertChildNavs = append(alertChildNavs, &navtree.NavLink{ Text: "New alert rule", SubTitle: "Create an alert rule", Id: "alert", - Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, + Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, IsCreateAction: true, }) } diff --git a/public/app/core/components/AppChrome/NavToolbarSeparator.tsx b/public/app/core/components/AppChrome/NavToolbarSeparator.tsx index 72b328ba2d9..36fe42e58b8 100644 --- a/public/app/core/components/AppChrome/NavToolbarSeparator.tsx +++ b/public/app/core/components/AppChrome/NavToolbarSeparator.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -6,18 +6,19 @@ import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; export interface Props { + className?: string; leftActionsSeparator?: boolean; } -export function NavToolbarSeparator({ leftActionsSeparator }: Props) { +export function NavToolbarSeparator({ className, leftActionsSeparator }: Props) { const styles = useStyles2(getStyles); if (leftActionsSeparator) { - return
; + return
; } if (config.featureToggles.topnav) { - return
; + return
; } return null; diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx new file mode 100644 index 00000000000..dbf20988fde --- /dev/null +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { NavModelItem, NavSection } from '@grafana/data'; +import { configureStore } from 'app/store/configureStore'; + +import { QuickAdd } from './QuickAdd'; + +const setup = () => { + const navBarTree: NavModelItem[] = [ + { + text: 'Section 1', + section: NavSection.Core, + id: 'section1', + url: 'section1', + children: [ + { text: 'New child 1', id: 'child1', url: 'section1/child1', isCreateAction: true }, + { text: 'Child2', id: 'child2', url: 'section1/child2' }, + ], + }, + { + text: 'Section 2', + id: 'section2', + section: NavSection.Config, + url: 'section2', + children: [{ text: 'New child 3', id: 'child3', url: 'section2/child3', isCreateAction: true }], + }, + ]; + + const store = configureStore({ navBarTree }); + + return render( + + + + ); +}; + +describe('QuickAdd', () => { + it('renders a `New` button', () => { + setup(); + expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument(); + }); + + it('renders the `New` text on a larger viewport', () => { + (window.matchMedia as jest.Mock).mockImplementation(() => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: () => false, + })); + setup(); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + it('does not render the text on a smaller viewport', () => { + (window.matchMedia as jest.Mock).mockImplementation(() => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: () => true, + })); + setup(); + expect(screen.queryByText('New')).not.toBeInTheDocument(); + }); + + it('shows isCreateAction options when clicked', async () => { + setup(); + await userEvent.click(screen.getByRole('button', { name: 'New' })); + expect(screen.getByRole('link', { name: 'New child 1' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'New child 3' })).toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx new file mode 100644 index 00000000000..13dbdcc9b65 --- /dev/null +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx @@ -0,0 +1,76 @@ +import { css } from '@emotion/css'; +import React, { useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Menu, Dropdown, Button, Icon, useStyles2, useTheme2, ToolbarButton } from '@grafana/ui'; +import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; +import { useSelector } from 'app/types'; + +import { NavToolbarSeparator } from '../NavToolbarSeparator'; + +import { findCreateActions } from './utils'; + +export interface Props {} + +export const QuickAdd = ({}: Props) => { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const navBarTree = useSelector((state) => state.navBarTree); + const breakpoint = theme.breakpoints.values.sm; + + const [isSmallScreen, setIsSmallScreen] = useState(window.matchMedia(`(max-width: ${breakpoint}px)`).matches); + const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]); + + useMediaQueryChange({ + breakpoint, + onChange: (e) => { + setIsSmallScreen(e.matches); + }, + }); + + const MenuActions = () => { + return ( + + {createActions.map((createAction, index) => ( + + ))} + + ); + }; + + return createActions.length > 0 ? ( + <> + + {isSmallScreen ? ( + + ) : ( + + )} + + + + ) : null; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + buttonContent: css({ + alignItems: 'center', + display: 'flex', + }), + buttonText: css({ + [theme.breakpoints.down('md')]: { + display: 'none', + }, + }), + separator: css({ + marginLeft: theme.spacing(1), + [theme.breakpoints.down('md')]: { + display: 'none', + }, + }), +}); diff --git a/public/app/core/components/AppChrome/QuickAdd/utils.ts b/public/app/core/components/AppChrome/QuickAdd/utils.ts new file mode 100644 index 00000000000..2e4a0075472 --- /dev/null +++ b/public/app/core/components/AppChrome/QuickAdd/utils.ts @@ -0,0 +1,14 @@ +import { NavModelItem } from '@grafana/data'; + +export function findCreateActions(navTree: NavModelItem[]): NavModelItem[] { + const results: NavModelItem[] = []; + for (const navItem of navTree) { + if (navItem.isCreateAction) { + results.push(navItem); + } + if (navItem.children) { + results.push(...findCreateActions(navItem.children)); + } + } + return results; +} diff --git a/public/app/core/components/AppChrome/TopSearchBar.tsx b/public/app/core/components/AppChrome/TopSearchBar.tsx index 679f7b388ae..b4244f43365 100644 --- a/public/app/core/components/AppChrome/TopSearchBar.tsx +++ b/public/app/core/components/AppChrome/TopSearchBar.tsx @@ -8,6 +8,7 @@ import { useSelector } from 'app/types'; import { NewsContainer } from './News/NewsContainer'; import { OrganizationSwitcher } from './Organization/OrganizationSwitcher'; +import { QuickAdd } from './QuickAdd/QuickAdd'; import { SignInLink } from './TopBar/SignInLink'; import { TopNavBarMenu } from './TopBar/TopNavBarMenu'; import { TopSearchBarSection } from './TopBar/TopSearchBarSection'; @@ -33,15 +34,16 @@ export function TopSearchBar() { + {helpNode && ( - }> + } placement="bottom-end"> )} {!contextSrv.user.isSignedIn && } {profileNode && ( - }> + } placement="bottom-end"> ({ layout: css({ height: TOP_BAR_LEVEL_HEIGHT, display: 'flex', - gap: theme.spacing(0.5), + gap: theme.spacing(1), alignItems: 'center', padding: theme.spacing(0, 2), borderBottom: `1px solid ${theme.colors.border.weak}`, justifyContent: 'space-between', [theme.breakpoints.up('sm')]: { - gridTemplateColumns: '1fr 2fr 1fr', + gridTemplateColumns: '1fr 1fr 1fr', display: 'grid', justifyContent: 'flex-start', @@ -83,7 +85,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ width: '24px', }, }), - newsButton: css({ [theme.breakpoints.down('sm')]: { display: 'none', diff --git a/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx b/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx index 3c2798e13cf..edfc7b8e4d1 100644 --- a/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx +++ b/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx @@ -39,7 +39,7 @@ export function NavBarMenuItemWrapper({ {link.children.map((childLink) => { const icon = childLink.icon ? toIconName(childLink.icon) : undefined; return ( - !childLink.divider && ( + !childLink.isCreateAction && ( Date: Tue, 15 Nov 2022 13:16:03 +0100 Subject: [PATCH 242/926] Search: Fixes issue with Recent/Starred section always displaying "General" folder (#58746) --- .../page/components/FolderSection.test.tsx | 33 +++++++++++++++++-- .../search/page/components/FolderSection.tsx | 4 +-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/public/app/features/search/page/components/FolderSection.test.tsx b/public/app/features/search/page/components/FolderSection.test.tsx index b77e5d1f216..9359618cc8e 100644 --- a/public/app/features/search/page/components/FolderSection.test.tsx +++ b/public/app/features/search/page/components/FolderSection.test.tsx @@ -26,7 +26,7 @@ describe('FolderSection', () => { window.localStorage.clear(); }); - describe('when where are no results', () => { + describe('when there are no results', () => { const emptySearchData: DataFrame = { fields: [ { name: 'kind', type: FieldType.string, config: {}, values: new ArrayVector([]) }, @@ -100,8 +100,19 @@ describe('FolderSection', () => { { name: 'uid', type: FieldType.string, config: {}, values: new ArrayVector(['my-dashboard-1']) }, { name: 'url', type: FieldType.string, config: {}, values: new ArrayVector(['/my-dashboard-1']) }, { name: 'tags', type: FieldType.other, config: {}, values: new ArrayVector([['foo', 'bar']]) }, - { name: 'location', type: FieldType.string, config: {}, values: new ArrayVector(['/my-dashboard-1']) }, + { name: 'location', type: FieldType.string, config: {}, values: new ArrayVector(['my-folder-1']) }, ], + meta: { + custom: { + locationInfo: { + 'my-folder-1': { + name: 'My folder 1', + kind: 'folder', + url: '/my-folder-1', + }, + }, + }, + }, length: 1, }; @@ -205,5 +216,23 @@ describe('FolderSection', () => { expect(mockSelectionToggle).toHaveBeenCalledWith('dashboard', 'my-dashboard-1'); }); }); + + describe('when in a pseudo-folder (i.e. Starred/Recent)', () => { + const mockRecentSection = { + kind: 'folder', + uid: '__recent', + title: 'Recent', + itemsUIDs: ['my-dashboard-1'], + }; + + it('shows the correct folder name next to the dashboard', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: mockRecentSection.title })); + expect(getGrafanaSearcher().search).toHaveBeenCalled(); + expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); + expect(await screen.findByText('My folder 1')).toBeInTheDocument(); + }); + }); }); }); diff --git a/public/app/features/search/page/components/FolderSection.tsx b/public/app/features/search/page/components/FolderSection.tsx index 1ca6f74a29a..c1e4f89b00a 100644 --- a/public/app/features/search/page/components/FolderSection.tsx +++ b/public/app/features/search/page/components/FolderSection.tsx @@ -82,8 +82,8 @@ export const FolderSection = ({ id: 666, // do not use me! isStarred: false, tags: item.tags ?? [], - folderUid, - folderTitle, + folderUid: folderUid || item.location, + folderTitle: folderTitle || raw.view.dataFrame.meta?.custom?.locationInfo[item.location].name, })); return v; }, [sectionExpanded, tags]); From 83bd57244d06734607b8b77acf350ad21aab8a09 Mon Sep 17 00:00:00 2001 From: "lean.dev" <34773040+leandro-deveikis@users.noreply.github.com> Date: Tue, 15 Nov 2022 10:03:05 -0300 Subject: [PATCH 243/926] Chore: Update version (#58750) --- .../alerting/testdata_alerts.json | 10 ++-- .../datasource-loki/loki_fakedata.json | 6 +-- .../panel-canvas/canvas-examples.json | 16 +++---- .../panel-geomap/geomap-photo-layer.json | 2 +- .../timeseries-out-of-rage.json | 10 ++-- devenv/docker/loadtest-ts/package.json | 2 +- .../loadtest-ts/src/get-large-dashboard.ts | 2 +- lerna.json | 2 +- package.json | 2 +- packages/grafana-data/package.json | 4 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-e2e/package.json | 4 +- packages/grafana-runtime/package.json | 8 ++-- packages/grafana-schema/package.json | 2 +- packages/grafana-toolkit/package.json | 6 +-- packages/grafana-ui/package.json | 8 ++-- packages/jaeger-ui-components/package.json | 10 ++-- .../internal/input-datasource/package.json | 8 ++-- .../__mocks__/store.navIndex.mock.ts | 2 +- yarn.lock | 46 +++++++++---------- 20 files changed, 76 insertions(+), 76 deletions(-) diff --git a/devenv/dev-dashboards/alerting/testdata_alerts.json b/devenv/dev-dashboards/alerting/testdata_alerts.json index 7101b5c6c41..61f4c763eab 100644 --- a/devenv/dev-dashboards/alerting/testdata_alerts.json +++ b/devenv/dev-dashboards/alerting/testdata_alerts.json @@ -106,7 +106,7 @@ "alertThreshold": true }, "percentage": false, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "pointradius": 5, "points": false, "renderer": "flot", @@ -415,7 +415,7 @@ "alertThreshold": true }, "percentage": false, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "pointradius": 5, "points": false, "renderer": "flot", @@ -553,7 +553,7 @@ "alertThreshold": true }, "percentage": false, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "pointradius": 5, "points": false, "renderer": "flot", @@ -695,7 +695,7 @@ "alertThreshold": true }, "percentage": false, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "pointradius": 5, "points": false, "renderer": "flot", @@ -834,4 +834,4 @@ "uid": "7MeksYbmk", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/devenv/dev-dashboards/datasource-loki/loki_fakedata.json b/devenv/dev-dashboards/datasource-loki/loki_fakedata.json index 48325084a46..5bdf2235647 100644 --- a/devenv/dev-dashboards/datasource-loki/loki_fakedata.json +++ b/devenv/dev-dashboards/datasource-loki/loki_fakedata.json @@ -208,7 +208,7 @@ "sort": "none" } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "datasource": { @@ -284,7 +284,7 @@ }, "textMode": "auto" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "datasource": { @@ -346,7 +346,7 @@ }, "textMode": "auto" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "datasource": { diff --git a/devenv/dev-dashboards/panel-canvas/canvas-examples.json b/devenv/dev-dashboards/panel-canvas/canvas-examples.json index 8f226dcc23e..e059ecc3699 100644 --- a/devenv/dev-dashboards/panel-canvas/canvas-examples.json +++ b/devenv/dev-dashboards/panel-canvas/canvas-examples.json @@ -54,7 +54,7 @@ "type": "testdata", "uid": "PD8C576611E62080A" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "fieldConfig": { "defaults": { "mappings": [ @@ -1164,7 +1164,7 @@ "content": "#### Wind Energy Demo\n\nIn this demo we are showcasing a basic wind farm. We are using the wind turbine element to visualize the rpm of each turbine. We also use metric value elements with text element labels to visualize each turbines operational status, energy output, and rpm.\n\nThe wind turbine element is an \"advanced\" element type that can be accessed by enabling \"Show advanced element types\" in canvas options.", "mode": "markdown" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "transparent": true, "type": "text" }, @@ -3314,7 +3314,7 @@ }, "showAdvancedTypes": false }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "Status_12, Status_13, Status_14, Throughput_12, Throughput_13, Throughput_14, Color_12, Color_13, Color_14\nReady, Blocked, Ready, 205, 0, 205, red, Red, Green", @@ -3350,7 +3350,7 @@ "content": "#### Level 2 Conveyance Demo\n\nThis example shows a basic factory conveyor layout from the top view. Using a similar approach, material flow metrics and equipment status can be monitored on a factory floor.", "mode": "markdown" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "transparent": true, "type": "text" }, @@ -3592,7 +3592,7 @@ }, "showAdvancedTypes": false }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "house_draw, battery_charge, solar_output\n1.1, 2.2, 3.3", @@ -3628,7 +3628,7 @@ "content": "#### Home Solar Energy Demo (Day)\n\nThis demo show cases an example off grid home solar system with batteries. In this example we set a background animated gif to represent our home solar system. We then overlayed metric values to represent the solar output, battery charging rate, and house energy drain.", "mode": "markdown" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "transparent": true, "type": "text" }, @@ -3808,7 +3808,7 @@ }, "showAdvancedTypes": false }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "solar_output, battery_draw\n0.0, 2.2", @@ -3844,7 +3844,7 @@ "content": "#### Home Solar Energy Demo (Night)\n\nThis demo show cases an example off grid home solar system with batteries at night. In this example we set a background animated gif to represent our home solar system. We then overlayed metric values to represent the solar output and battery draining rate / house energy drain.", "mode": "markdown" }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "transparent": true, "type": "text" } diff --git a/devenv/dev-dashboards/panel-geomap/geomap-photo-layer.json b/devenv/dev-dashboards/panel-geomap/geomap-photo-layer.json index 08ae930eb4a..389c1bb32e8 100644 --- a/devenv/dev-dashboards/panel-geomap/geomap-photo-layer.json +++ b/devenv/dev-dashboards/panel-geomap/geomap-photo-layer.json @@ -115,7 +115,7 @@ "zoom": 15 } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "state,src\nAL,https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Flag_of_Alabama.svg/320px-Flag_of_Alabama.svg.png\nAK,https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/Flag_of_Alaska.svg/320px-Flag_of_Alaska.svg.png\nAZ,https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Flag_of_Arizona.svg/320px-Flag_of_Arizona.svg.png\nAR,https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Flag_of_Arkansas.svg/320px-Flag_of_Arkansas.svg.png\nCA,https://upload.wikimedia.org/wikipedia/commons/thumb/0/01/Flag_of_California.svg/320px-Flag_of_California.svg.png\nCO,https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Flag_of_Colorado.svg/320px-Flag_of_Colorado.svg.png\nCT,https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Flag_of_Connecticut.svg/304px-Flag_of_Connecticut.svg.png\nDE,https://upload.wikimedia.org/wikipedia/commons/thumb/c/c6/Flag_of_Delaware.svg/320px-Flag_of_Delaware.svg.png\nFL,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Flag_of_Florida.svg/320px-Flag_of_Florida.svg.png\nGA,https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Flag_of_Georgia_%28U.S._state%29.svg/320px-Flag_of_Georgia_%28U.S._state%29.svg.png\nHI,https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Flag_of_Hawaii.svg/320px-Flag_of_Hawaii.svg.png\nID,https://upload.wikimedia.org/wikipedia/commons/thumb/a/a4/Flag_of_Idaho.svg/305px-Flag_of_Idaho.svg.png\nIL,https://upload.wikimedia.org/wikipedia/commons/thumb/0/01/Flag_of_Illinois.svg/320px-Flag_of_Illinois.svg.png\nIN,https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Flag_of_Indiana.svg/320px-Flag_of_Indiana.svg.png\nIA,https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Flag_of_Iowa.svg/320px-Flag_of_Iowa.svg.png\nKS,https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Flag_of_Kansas.svg/320px-Flag_of_Kansas.svg.png\nKY,https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Flag_of_Kentucky.svg/320px-Flag_of_Kentucky.svg.png\nLA,https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Flag_of_Louisiana.svg/320px-Flag_of_Louisiana.svg.png\nME,https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Flag_of_the_State_of_Maine.svg/305px-Flag_of_the_State_of_Maine.svg.png\nMD,https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Flag_of_Maryland.svg/320px-Flag_of_Maryland.svg.png\nMA,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Flag_of_Massachusetts.svg/320px-Flag_of_Massachusetts.svg.png\nMI,https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Flag_of_Michigan.svg/320px-Flag_of_Michigan.svg.png\nMN,https://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Flag_of_Minnesota.svg/320px-Flag_of_Minnesota.svg.png\nMS,https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Flag_of_Mississippi.svg/320px-Flag_of_Mississippi.svg.png\nMO,https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Flag_of_Missouri.svg/320px-Flag_of_Missouri.svg.png\nMT,https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Flag_of_Montana.svg/320px-Flag_of_Montana.svg.png\nNE,https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Flag_of_Nebraska.svg/320px-Flag_of_Nebraska.svg.png\nNV,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Flag_of_Nevada.svg/320px-Flag_of_Nevada.svg.png\nNH,https://upload.wikimedia.org/wikipedia/commons/thumb/2/28/Flag_of_New_Hampshire.svg/320px-Flag_of_New_Hampshire.svg.png\nNJ,https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/Flag_of_New_Jersey.svg/320px-Flag_of_New_Jersey.svg.png\nNM,https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Flag_of_New_Mexico.svg/320px-Flag_of_New_Mexico.svg.png\nNY,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Flag_of_New_York.svg/320px-Flag_of_New_York.svg.png\nNC,https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/Flag_of_North_Carolina.svg/320px-Flag_of_North_Carolina.svg.png\nND,https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/Flag_of_North_Dakota.svg/305px-Flag_of_North_Dakota.svg.png\nOH,https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Flag_of_Ohio.svg/320px-Flag_of_Ohio.svg.png\nOK,https://upload.wikimedia.org/wikipedia/commons/thumb/6/6e/Flag_of_Oklahoma.svg/320px-Flag_of_Oklahoma.svg.png\nOR,https://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Flag_of_Oregon.svg/320px-Flag_of_Oregon.svg.png\nPA,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Flag_of_Pennsylvania.svg/320px-Flag_of_Pennsylvania.svg.png\nRI,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Flag_of_Rhode_Island.svg/273px-Flag_of_Rhode_Island.svg.png\nSC,https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/Flag_of_South_Carolina.svg/320px-Flag_of_South_Carolina.svg.png\nSD,https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Flag_of_South_Dakota.svg/320px-Flag_of_South_Dakota.svg.png\nTN,https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Flag_of_Tennessee.svg/320px-Flag_of_Tennessee.svg.png\nTX,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Flag_of_Texas.svg/320px-Flag_of_Texas.svg.png\nUT,https://upload.wikimedia.org/wikipedia/commons/thumb/f/f6/Flag_of_Utah.svg/320px-Flag_of_Utah.svg.png\nVT,https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Flag_of_Vermont.svg/320px-Flag_of_Vermont.svg.png\nVA,https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Flag_of_Virginia.svg/320px-Flag_of_Virginia.svg.png\nWA,https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Flag_of_Washington.svg/320px-Flag_of_Washington.svg.png\nWV,https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Flag_of_West_Virginia.svg/320px-Flag_of_West_Virginia.svg.png\nWI,https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Flag_of_Wisconsin.svg/320px-Flag_of_Wisconsin.svg.png\nWY,https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Flag_of_Wyoming.svg/320px-Flag_of_Wyoming.svg.png\n", diff --git a/devenv/dev-dashboards/panel-timeseries/timeseries-out-of-rage.json b/devenv/dev-dashboards/panel-timeseries/timeseries-out-of-rage.json index 1ca036ac71e..079c4805565 100644 --- a/devenv/dev-dashboards/panel-timeseries/timeseries-out-of-rage.json +++ b/devenv/dev-dashboards/panel-timeseries/timeseries-out-of-rage.json @@ -105,7 +105,7 @@ "sort": "none" } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "Time,Value,Name\n2022-09-01T05:00:00Z,100,Before\n2022-09-01T06:00:00Z,100,Middle", @@ -197,7 +197,7 @@ "sort": "none" } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "Time,Value,Name\n2022-09-01T05:00:00Z,100,Before\n2022-09-01T07:00:00Z,100,After\n", @@ -289,7 +289,7 @@ "sort": "none" } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "Time,Value,Name\n2022-09-01T06:00:00Z,100,Middle\n2022-09-01T07:00:00Z,100,After\n", @@ -381,7 +381,7 @@ "sort": "none" } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "Time,Value,Name\n2022-09-01T04:00:00Z,100,Before1\n2022-09-01T05:00:00Z,100,Before2\n", @@ -473,7 +473,7 @@ "sort": "none" } }, - "pluginVersion": "9.3.0-pre", + "pluginVersion": "9.4.0-pre", "targets": [ { "csvContent": "Time,Value,Name\n2022-09-01T07:00:00Z,100,After1\n2022-09-01T08:00:00Z,100,After2\n", diff --git a/devenv/docker/loadtest-ts/package.json b/devenv/docker/loadtest-ts/package.json index 3c2a8d96d00..20368fd20d3 100644 --- a/devenv/docker/loadtest-ts/package.json +++ b/devenv/docker/loadtest-ts/package.json @@ -2,7 +2,7 @@ "private": true, "license": "Apache-2.0", "name": "@grafana/perf-tests", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "devDependencies": { "@babel/core": "7.19.6", "@babel/plugin-proposal-class-properties": "7.18.6", diff --git a/devenv/docker/loadtest-ts/src/get-large-dashboard.ts b/devenv/docker/loadtest-ts/src/get-large-dashboard.ts index 10c4a22c999..12211dde391 100644 --- a/devenv/docker/loadtest-ts/src/get-large-dashboard.ts +++ b/devenv/docker/loadtest-ts/src/get-large-dashboard.ts @@ -54,7 +54,7 @@ const testDash = { }, showHeader: true, }, - pluginVersion: '9.3.0-pre', + pluginVersion: '9.4.0-pre', targets: [ { csvContent: '', diff --git a/lerna.json b/lerna.json index b7c9baeae56..8e928f2dfcf 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "npmClient": "yarn", "useWorkspaces": true, "packages": ["packages/*"], - "version": "9.3.0-pre" + "version": "9.4.0-pre" } diff --git a/package.json b/package.json index d4f1cbff496..1d124bc97a4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "license": "AGPL-3.0-only", "private": true, "name": "grafana", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "repository": "github:grafana/grafana", "scripts": { "build": "yarn i18n:compile && NODE_ENV=production webpack --config scripts/webpack/webpack.prod.js", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 35e511e0a46..863a05c0335 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/data", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana Data Library", "keywords": [ "typescript" @@ -34,7 +34,7 @@ }, "dependencies": { "@braintree/sanitize-url": "6.0.1", - "@grafana/schema": "9.3.0-pre", + "@grafana/schema": "9.4.0-pre", "@types/d3-interpolate": "^1.4.0", "d3-interpolate": "1.4.0", "date-fns": "2.29.3", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index b6dafa85589..db889edf461 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e-selectors", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana End-to-End Test Selectors Library", "keywords": [ "cli", diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 8ef80c372f9..793424e5ca8 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/e2e", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana End-to-End Test Library", "keywords": [ "cli", @@ -61,7 +61,7 @@ "@babel/core": "7.19.6", "@babel/preset-env": "7.19.4", "@cypress/webpack-preprocessor": "5.15.2", - "@grafana/e2e-selectors": "9.3.0-pre", + "@grafana/e2e-selectors": "9.4.0-pre", "@grafana/tsconfig": "^1.2.0-rc1", "@mochajs/json-file-reporter": "^1.2.0", "babel-loader": "9.1.0", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 37bae34afee..6615eb7c6ce 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/runtime", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana Runtime Library", "keywords": [ "grafana", @@ -35,10 +35,10 @@ "typecheck": "tsc --emitDeclarationOnly false --noEmit" }, "dependencies": { - "@grafana/data": "9.3.0-pre", - "@grafana/e2e-selectors": "9.3.0-pre", + "@grafana/data": "9.4.0-pre", + "@grafana/e2e-selectors": "9.4.0-pre", "@grafana/faro-web-sdk": "1.0.0-beta2", - "@grafana/ui": "9.3.0-pre", + "@grafana/ui": "9.4.0-pre", "@sentry/browser": "6.19.7", "history": "4.10.1", "lodash": "4.17.21", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index d2c08dfe2b1..6c82f2e2bbb 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/schema", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana Schema Library", "keywords": [ "typescript" diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index ea743899f3d..9ad26481b13 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/toolkit", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana Toolkit", "keywords": [ "grafana", @@ -51,10 +51,10 @@ "@babel/preset-env": "7.18.9", "@babel/preset-react": "7.18.6", "@babel/preset-typescript": "7.18.6", - "@grafana/data": "9.3.0-pre", + "@grafana/data": "9.4.0-pre", "@grafana/eslint-config": "5.0.0", "@grafana/tsconfig": "^1.2.0-rc1", - "@grafana/ui": "9.3.0-pre", + "@grafana/ui": "9.4.0-pre", "@jest/core": "27.5.1", "@types/command-exists": "^1.2.0", "@types/eslint": "8.4.1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index b6b9691ca9f..e69b1f0876c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -2,7 +2,7 @@ "author": "Grafana Labs", "license": "Apache-2.0", "name": "@grafana/ui", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Grafana Components Library", "keywords": [ "grafana", @@ -47,9 +47,9 @@ "dependencies": { "@emotion/css": "11.10.5", "@emotion/react": "11.10.5", - "@grafana/data": "9.3.0-pre", - "@grafana/e2e-selectors": "9.3.0-pre", - "@grafana/schema": "9.3.0-pre", + "@grafana/data": "9.4.0-pre", + "@grafana/e2e-selectors": "9.4.0-pre", + "@grafana/schema": "9.4.0-pre", "@leeoniya/ufuzzy": "0.8.0", "@monaco-editor/react": "4.4.6", "@popperjs/core": "2.11.6", diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json index 8810297f568..1fc08e847db 100644 --- a/packages/jaeger-ui-components/package.json +++ b/packages/jaeger-ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@jaegertracing/jaeger-ui-components", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ }, "dependencies": { "@emotion/css": "11.10.5", - "@grafana/data": "9.3.0-pre", - "@grafana/e2e-selectors": "9.3.0-pre", - "@grafana/runtime": "9.3.0-pre", - "@grafana/ui": "9.3.0-pre", + "@grafana/data": "9.4.0-pre", + "@grafana/e2e-selectors": "9.4.0-pre", + "@grafana/runtime": "9.4.0-pre", + "@grafana/ui": "9.4.0-pre", "chance": "^1.0.10", "classnames": "^2.2.5", "combokeys": "^3.0.0", diff --git a/plugins-bundled/internal/input-datasource/package.json b/plugins-bundled/internal/input-datasource/package.json index dba38aa6d60..dbfc07a9fb2 100644 --- a/plugins-bundled/internal/input-datasource/package.json +++ b/plugins-bundled/internal/input-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@grafana-plugins/input-datasource", - "version": "9.3.0-pre", + "version": "9.4.0-pre", "description": "Input Datasource", "private": true, "repository": { @@ -15,15 +15,15 @@ }, "author": "Grafana Labs", "devDependencies": { - "@grafana/toolkit": "9.3.0-pre", + "@grafana/toolkit": "9.4.0-pre", "@types/jest": "26.0.15", "@types/lodash": "4.14.149", "@types/react": "17.0.30", "lodash": "4.17.21" }, "dependencies": { - "@grafana/data": "9.3.0-pre", - "@grafana/ui": "9.3.0-pre", + "@grafana/data": "9.4.0-pre", + "@grafana/ui": "9.4.0-pre", "jquery": "3.5.1", "react": "17.0.1", "react-dom": "17.0.1", diff --git a/public/app/features/connections/__mocks__/store.navIndex.mock.ts b/public/app/features/connections/__mocks__/store.navIndex.mock.ts index 31507f66744..f9d48a17daa 100644 --- a/public/app/features/connections/__mocks__/store.navIndex.mock.ts +++ b/public/app/features/connections/__mocks__/store.navIndex.mock.ts @@ -1169,7 +1169,7 @@ export const navIndex: NavIndex = { id: 'help', text: 'Help', section: NavSection.Config, - subTitle: 'Grafana v9.3.0-pre (8f5dc47e87)', + subTitle: 'Grafana v9.4.0-pre (8f5dc47e87)', icon: 'question-circle', url: '#', sortWeight: -500, diff --git a/yarn.lock b/yarn.lock index cf67ffc6d1e..c97b0ca2335 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4275,9 +4275,9 @@ __metadata: version: 0.0.0-use.local resolution: "@grafana-plugins/input-datasource@workspace:plugins-bundled/internal/input-datasource" dependencies: - "@grafana/data": 9.3.0-pre - "@grafana/toolkit": 9.3.0-pre - "@grafana/ui": 9.3.0-pre + "@grafana/data": 9.4.0-pre + "@grafana/toolkit": 9.4.0-pre + "@grafana/ui": 9.4.0-pre "@types/jest": 26.0.15 "@types/lodash": 4.14.149 "@types/react": 17.0.30 @@ -4298,12 +4298,12 @@ __metadata: languageName: node linkType: hard -"@grafana/data@9.3.0-pre, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data": +"@grafana/data@9.4.0-pre, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data": version: 0.0.0-use.local resolution: "@grafana/data@workspace:packages/grafana-data" dependencies: "@braintree/sanitize-url": 6.0.1 - "@grafana/schema": 9.3.0-pre + "@grafana/schema": 9.4.0-pre "@grafana/tsconfig": ^1.2.0-rc1 "@rollup/plugin-commonjs": 23.0.2 "@rollup/plugin-json": 5.0.1 @@ -4362,7 +4362,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/e2e-selectors@9.3.0-pre, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors": +"@grafana/e2e-selectors@9.4.0-pre, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors": version: 0.0.0-use.local resolution: "@grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors" dependencies: @@ -4388,7 +4388,7 @@ __metadata: "@babel/core": 7.19.6 "@babel/preset-env": 7.19.4 "@cypress/webpack-preprocessor": 5.15.2 - "@grafana/e2e-selectors": 9.3.0-pre + "@grafana/e2e-selectors": 9.4.0-pre "@grafana/tsconfig": ^1.2.0-rc1 "@mochajs/json-file-reporter": ^1.2.0 "@rollup/plugin-node-resolve": 15.0.1 @@ -4507,15 +4507,15 @@ __metadata: languageName: node linkType: hard -"@grafana/runtime@9.3.0-pre, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime": +"@grafana/runtime@9.4.0-pre, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime": version: 0.0.0-use.local resolution: "@grafana/runtime@workspace:packages/grafana-runtime" dependencies: - "@grafana/data": 9.3.0-pre - "@grafana/e2e-selectors": 9.3.0-pre + "@grafana/data": 9.4.0-pre + "@grafana/e2e-selectors": 9.4.0-pre "@grafana/faro-web-sdk": 1.0.0-beta2 "@grafana/tsconfig": ^1.2.0-rc1 - "@grafana/ui": 9.3.0-pre + "@grafana/ui": 9.4.0-pre "@rollup/plugin-commonjs": 23.0.2 "@rollup/plugin-node-resolve": 15.0.1 "@sentry/browser": 6.19.7 @@ -4552,7 +4552,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/schema@9.3.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema": +"@grafana/schema@9.4.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema": version: 0.0.0-use.local resolution: "@grafana/schema@workspace:packages/grafana-schema" dependencies: @@ -4572,7 +4572,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/toolkit@9.3.0-pre, @grafana/toolkit@workspace:*, @grafana/toolkit@workspace:packages/grafana-toolkit": +"@grafana/toolkit@9.4.0-pre, @grafana/toolkit@workspace:*, @grafana/toolkit@workspace:packages/grafana-toolkit": version: 0.0.0-use.local resolution: "@grafana/toolkit@workspace:packages/grafana-toolkit" dependencies: @@ -4588,10 +4588,10 @@ __metadata: "@babel/preset-env": 7.18.9 "@babel/preset-react": 7.18.6 "@babel/preset-typescript": 7.18.6 - "@grafana/data": 9.3.0-pre + "@grafana/data": 9.4.0-pre "@grafana/eslint-config": 5.0.0 "@grafana/tsconfig": ^1.2.0-rc1 - "@grafana/ui": 9.3.0-pre + "@grafana/ui": 9.4.0-pre "@jest/core": 27.5.1 "@types/command-exists": ^1.2.0 "@types/eslint": 8.4.1 @@ -4672,16 +4672,16 @@ __metadata: languageName: node linkType: hard -"@grafana/ui@9.3.0-pre, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui": +"@grafana/ui@9.4.0-pre, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui": version: 0.0.0-use.local resolution: "@grafana/ui@workspace:packages/grafana-ui" dependencies: "@babel/core": 7.19.6 "@emotion/css": 11.10.5 "@emotion/react": 11.10.5 - "@grafana/data": 9.3.0-pre - "@grafana/e2e-selectors": 9.3.0-pre - "@grafana/schema": 9.3.0-pre + "@grafana/data": 9.4.0-pre + "@grafana/e2e-selectors": 9.4.0-pre + "@grafana/schema": 9.4.0-pre "@grafana/tsconfig": ^1.2.0-rc1 "@leeoniya/ufuzzy": 0.8.0 "@mdx-js/react": 1.6.22 @@ -4956,11 +4956,11 @@ __metadata: resolution: "@jaegertracing/jaeger-ui-components@workspace:packages/jaeger-ui-components" dependencies: "@emotion/css": 11.10.5 - "@grafana/data": 9.3.0-pre - "@grafana/e2e-selectors": 9.3.0-pre - "@grafana/runtime": 9.3.0-pre + "@grafana/data": 9.4.0-pre + "@grafana/e2e-selectors": 9.4.0-pre + "@grafana/runtime": 9.4.0-pre "@grafana/tsconfig": ^1.2.0-rc1 - "@grafana/ui": 9.3.0-pre + "@grafana/ui": 9.4.0-pre "@testing-library/jest-dom": 5.16.5 "@testing-library/react": 12.1.4 "@testing-library/user-event": 14.4.3 From ff1afbb6994eb2c5fafca5ac9adcf5d9d319fa17 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 15 Nov 2022 13:47:14 +0000 Subject: [PATCH 244/926] Revert "Chore: move to node 18 (#58570)" (#58754) This reverts commit 0a9129cf90a7134411f8aeca1c0bd56b112e9371. --- .drone.yml | 384 +++++++++--------- .nvmrc | 2 +- Dockerfile | 2 +- Dockerfile.ubuntu | 2 +- contribute/developer-guide.md | 2 +- package.json | 2 +- .../SharePublicDashboard.test.tsx | 3 +- scripts/build/ci-build/Dockerfile | 4 +- scripts/build/ci-build/README.md | 2 - scripts/drone/steps/lib.star | 2 +- 10 files changed, 201 insertions(+), 204 deletions(-) diff --git a/.drone.yml b/.drone.yml index 1be3ebcab5b..3a72db10b15 100644 --- a/.drone.yml +++ b/.drone.yml @@ -80,13 +80,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -94,7 +94,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-frontend trigger: event: @@ -135,7 +135,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn run prettier:check @@ -146,7 +146,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: lint-frontend trigger: event: @@ -200,7 +200,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -208,25 +208,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: event: @@ -278,7 +278,7 @@ steps: - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update && apt-get install make @@ -348,7 +348,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -356,18 +356,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git" @@ -392,7 +392,7 @@ steps: from_secret: github_token_pr TEST_TAG: v0.0.0-test failure: ignore - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: trigger-test-release when: paths: @@ -419,7 +419,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -428,7 +428,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -437,7 +437,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -445,7 +445,7 @@ steps: - compile-build-cmd - yarn-install environment: null - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - . scripts/build/gpg-test-vars.sh && ./bin/build package --jobs 8 --edition oss @@ -456,7 +456,7 @@ steps: - build-frontend - build-frontend-packages environment: null - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package - commands: - ./scripts/grafana-server/start-server @@ -469,7 +469,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -571,7 +571,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -582,7 +582,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -682,7 +682,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -690,13 +690,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -712,7 +712,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -728,7 +728,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: event: @@ -784,7 +784,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - |- @@ -796,7 +796,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: codespell - commands: - yarn run prettier:checkDocs @@ -804,7 +804,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -852,7 +852,7 @@ steps: - ./bin/build shellcheck depends_on: - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: shellcheck trigger: event: @@ -897,7 +897,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - |- @@ -909,7 +909,7 @@ steps: wan" > words_to_ignore.txt - codespell -I words_to_ignore.txt docs/ - rm words_to_ignore.txt - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: codespell - commands: - yarn run prettier:checkDocs @@ -917,7 +917,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: lint-docs - commands: - mkdir -p /hugo/content/docs/grafana @@ -968,13 +968,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -982,7 +982,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-frontend trigger: branch: main @@ -1020,7 +1020,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn run prettier:check @@ -1031,7 +1031,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: lint-frontend trigger: branch: main @@ -1082,7 +1082,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1090,25 +1090,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: branch: main @@ -1153,7 +1153,7 @@ steps: - commands: - make gen-go depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update && apt-get install make @@ -1223,7 +1223,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1231,25 +1231,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - ./bin/build build-backend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1258,7 +1258,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -1267,7 +1267,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1277,7 +1277,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -1295,7 +1295,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package - commands: - ./scripts/grafana-server/start-server @@ -1308,7 +1308,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -1410,7 +1410,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -1421,7 +1421,7 @@ steps: - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - yarn wait-on http://$HOST:$PORT @@ -1465,7 +1465,7 @@ steps: GRAFANA_MISC_STATS_API_KEY: from_secret: grafana_misc_stats_api_key failure: ignore - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: publish-frontend-metrics when: repo: @@ -1546,7 +1546,7 @@ steps: environment: NPM_TOKEN: from_secret: npm_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: release-canary-npm-packages when: repo: @@ -1655,7 +1655,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -1663,13 +1663,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -1685,7 +1685,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -1701,7 +1701,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: branch: main @@ -1935,18 +1935,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -1960,7 +1960,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss ${DRONE_TAG} @@ -1969,7 +1969,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss ${DRONE_TAG} @@ -1978,7 +1978,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -1988,7 +1988,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --sign ${DRONE_TAG} @@ -2006,14 +2006,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -2052,7 +2052,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -2129,7 +2129,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-storybook when: event: @@ -2187,7 +2187,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: store-npm-packages trigger: event: @@ -2234,13 +2234,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2248,7 +2248,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-frontend trigger: event: @@ -2296,7 +2296,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2304,25 +2304,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: event: @@ -2389,7 +2389,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2397,13 +2397,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -2419,7 +2419,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -2435,7 +2435,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: event: @@ -2552,7 +2552,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2568,7 +2568,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -2582,13 +2582,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -2598,7 +2598,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -2607,14 +2607,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2623,7 +2623,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise ${DRONE_TAG} @@ -2632,7 +2632,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -2642,14 +2642,14 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 ${DRONE_TAG} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise --sign ${DRONE_TAG} @@ -2668,14 +2668,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -2715,7 +2715,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -2823,7 +2823,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 @@ -2845,7 +2845,7 @@ steps: from_secret: gcp_key PRERELEASE_BUCKET: from_secret: prerelease_bucket - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: store-npm-packages - commands: - ./bin/grabpl upload-packages --edition enterprise2 @@ -2897,7 +2897,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -2913,7 +2913,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -2929,14 +2929,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -2945,7 +2945,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-frontend trigger: event: @@ -2982,7 +2982,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mkdir -p bin @@ -3004,7 +3004,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -3026,7 +3026,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3035,25 +3035,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: event: @@ -3126,7 +3126,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -3142,7 +3142,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -3152,7 +3152,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -3161,13 +3161,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -3183,7 +3183,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -3199,7 +3199,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -3208,7 +3208,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -3217,7 +3217,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: memcached-integration-tests trigger: event: @@ -3658,7 +3658,7 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - ./bin/grabpl artifacts npm retrieve --tag v${TAG} @@ -3680,7 +3680,7 @@ steps: NPM_TOKEN: from_secret: npm_token failure: ignore - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: release-npm-packages trigger: event: @@ -3910,7 +3910,7 @@ steps: environment: GCP_KEY: from_secret: gcp_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: artifacts-page trigger: event: @@ -3955,18 +3955,18 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -3980,7 +3980,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -3989,7 +3989,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} @@ -3998,7 +3998,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition oss @@ -4008,7 +4008,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build package --jobs 8 --edition oss --build-id ${DRONE_BUILD_NUMBER} --sign @@ -4026,14 +4026,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition oss --shouldSave @@ -4072,7 +4072,7 @@ steps: environment: ARCH: linux-amd64 PORT: 3001 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -4149,7 +4149,7 @@ steps: - build-frontend-packages environment: NODE_OPTIONS: --max_old_space_size=4096 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-storybook when: paths: @@ -4228,13 +4228,13 @@ steps: - commands: - yarn install --immutable depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - yarn-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4242,7 +4242,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-frontend trigger: ref: @@ -4287,7 +4287,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4295,25 +4295,25 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: ref: @@ -4377,7 +4377,7 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4385,13 +4385,13 @@ steps: in output.' - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -4407,7 +4407,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -4423,7 +4423,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: mysql-integration-tests trigger: ref: @@ -4530,7 +4530,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -4545,7 +4545,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - go build -o ./bin/build -ldflags '-extldflags -static' ./pkg/build/cmd @@ -4559,13 +4559,13 @@ steps: - make gen-go depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -4575,7 +4575,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -4584,14 +4584,14 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - ./bin/build build-backend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend - commands: - ./bin/build build-frontend --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4600,7 +4600,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend - commands: - ./bin/build build-frontend-packages --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4609,7 +4609,7 @@ steps: - yarn-install environment: NODE_OPTIONS: --max_old_space_size=8192 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-frontend-packages - commands: - ./bin/build build-plugins --jobs 8 --edition enterprise @@ -4619,7 +4619,7 @@ steps: environment: GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-plugins - commands: - ./bin/build build-backend --jobs 8 --edition enterprise2 --build-id ${DRONE_BUILD_NUMBER} @@ -4627,7 +4627,7 @@ steps: depends_on: - wire-install - compile-build-cmd - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: build-backend-enterprise2 - commands: - ./bin/build package --jobs 8 --edition enterprise --build-id ${DRONE_BUILD_NUMBER} @@ -4647,14 +4647,14 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package - commands: - ls dist/*.tar.gz* - cp dist/*.tar.gz* packaging/docker/ depends_on: - package - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: copy-packages-for-docker - commands: - ./bin/build build-docker --edition enterprise --shouldSave @@ -4694,7 +4694,7 @@ steps: ARCH: linux-amd64 PORT: 3001 RUNDIR: scripts/grafana-server/tmp-grafana-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: grafana-server - commands: - apt-get install -y netcat @@ -4809,7 +4809,7 @@ steps: from_secret: gpg_pub_key GRAFANA_API_KEY: from_secret: grafana_api_key - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - ./bin/grabpl upload-cdn --edition enterprise2 @@ -4869,7 +4869,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -4884,7 +4884,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -4900,14 +4900,14 @@ steps: - yarn install --immutable depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: yarn-install - commands: - yarn betterer ci depends_on: - init-enterprise - yarn-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: betterer-frontend - commands: - yarn run ci:test-frontend @@ -4916,7 +4916,7 @@ steps: - yarn-install environment: TEST_MAX_WORKERS: 50% - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-frontend trigger: ref: @@ -4950,7 +4950,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mkdir -p bin @@ -4971,7 +4971,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - echo $DRONE_RUNNER_NAME @@ -4993,7 +4993,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5002,25 +5002,25 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - go test -short -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend - commands: - go test -run Integration -covermode=atomic -timeout=5m ./pkg/... depends_on: - wire-install - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: test-backend-integration trigger: ref: @@ -5090,7 +5090,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: clone-enterprise - commands: - mv bin/grabpl /tmp/ @@ -5105,7 +5105,7 @@ steps: environment: GITHUB_TOKEN: from_secret: github_token - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: init-enterprise - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -5115,7 +5115,7 @@ steps: - CODEGEN_VERIFY=1 make gen-cue depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -5124,13 +5124,13 @@ steps: - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: - init-enterprise - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: verify-gen-jsonnet - commands: - make gen-go depends_on: - verify-gen-cue - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: wire-install - commands: - apt-get update @@ -5146,7 +5146,7 @@ steps: GRAFANA_TEST_DB: postgres PGPASSWORD: grafanatest POSTGRES_HOST: postgres - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: postgres-integration-tests - commands: - apt-get update @@ -5162,7 +5162,7 @@ steps: environment: GRAFANA_TEST_DB: mysql MYSQL_HOST: mysql - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: mysql-integration-tests - commands: - dockerize -wait tcp://redis:6379/0 -timeout 120s @@ -5171,7 +5171,7 @@ steps: - wire-install environment: REDIS_URL: redis://redis:6379/0 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: redis-integration-tests - commands: - dockerize -wait tcp://memcached:11211 -timeout 120s @@ -5180,7 +5180,7 @@ steps: - wire-install environment: MEMCACHED_HOSTS: memcached:11211 - image: grafana/build-container:1.6.5 + image: grafana/build-container:1.6.4 name: memcached-integration-tests trigger: ref: @@ -5512,6 +5512,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 4f5e09af0ec5a9d59c5e31333bf180dd52cba1ad2780d96a62d20583113ccb16 +hmac: 2b1b5ade4e8007a9d5b76ec2dbc9e647ca0938e00713b3ad8e8163bce2db40a6 ... diff --git a/.nvmrc b/.nvmrc index 9dfef472196..bf79505bb85 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.12.0 +v16.14.0 diff --git a/Dockerfile b/Dockerfile index c79dadd34f5..1032ba60ae7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine3.15 as js-builder +FROM node:16-alpine3.15 as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index a7835b5513a..077d97a0c99 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -1,4 +1,4 @@ -FROM node:18-alpine3.15 as js-builder +FROM node:16-alpine3.15 as js-builder ENV NODE_OPTIONS=--max_old_space_size=8000 diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index fb20192b939..202d9e192b2 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -18,7 +18,7 @@ We recommend using [Homebrew](https://brew.sh/) for installing any missing depen ``` brew install git brew install go -brew install node@18 +brew install node@16 npm install -g yarn ``` diff --git a/package.json b/package.json index 1d124bc97a4..fb0faba2452 100644 --- a/package.json +++ b/package.json @@ -431,7 +431,7 @@ ] }, "engines": { - "node": ">= 18" + "node": ">= 16" }, "packageManager": "yarn@3.2.4" } diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index f26c0a30e93..808826c68ba 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -71,7 +71,6 @@ beforeEach(() => { config.featureToggles.publicDashboards = true; mockDashboard = new DashboardModel({ uid: 'mockDashboardUid', - timezone: 'utc', }); mockPanel = new PanelModel({ @@ -146,7 +145,7 @@ describe('SharePublic', () => { await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); await screen.findByText('Welcome to Grafana public dashboards alpha!'); - expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 00:59:59')).toBeInTheDocument(); + expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 01:59:59')).toBeInTheDocument(); }); it('when modal is opened, then loader spinner appears and inputs are disabled', async () => { mockDashboard.meta.hasPublicDashboard = true; diff --git a/scripts/build/ci-build/Dockerfile b/scripts/build/ci-build/Dockerfile index c78c1a3249f..89b48b10d48 100644 --- a/scripts/build/ci-build/Dockerfile +++ b/scripts/build/ci-build/Dockerfile @@ -105,7 +105,7 @@ FROM debian:buster-20220822 ENV GOVERSION=1.19.3 \ PATH=/usr/local/go/bin:$PATH \ GOPATH=/go \ - NODEVERSION=18.12.0-1nodesource1 \ + NODEVERSION=16.14.0-1nodesource1 \ YARNVERSION=1.22.19-1 # Use ARG so as not to persist environment variable in image @@ -141,7 +141,7 @@ RUN apt-get update && \ gem install --conservative -N fpm && \ ln -s /usr/bin/llvm-dsymutil-6.0 /usr/bin/dsymutil && \ curl -fsS https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - && \ - curl -O https://deb.nodesource.com/node_18.x/pool/main/n/nodejs/nodejs_${NODEVERSION}_amd64.deb &&\ + curl -O https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_${NODEVERSION}_amd64.deb &&\ dpkg -i nodejs_${NODEVERSION}_amd64.deb &&\ rm nodejs_${NODEVERSION}_amd64.deb &&\ curl -fsS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ diff --git a/scripts/build/ci-build/README.md b/scripts/build/ci-build/README.md index 685324a24a7..00d396d4b36 100644 --- a/scripts/build/ci-build/README.md +++ b/scripts/build/ci-build/README.md @@ -14,5 +14,3 @@ In order to build and publish the Grafana build Docker image, execute the follow docker build -t grafana/build-container: . docker push grafana/build-container: ``` - -If you're running on a machine that has an ARM chip (Apple M1/M2, etc.), add `--platform linux/amd64` to the `docker build` command. It can take approximately four hours for an initial build to complete. Due to caching, subsequent builds take less time. diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 0fc7f6f1487..14d09dd5fa7 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -1,7 +1,7 @@ load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket') grabpl_version = 'v3.0.16' -build_image = 'grafana/build-container:1.6.5' +build_image = 'grafana/build-container:1.6.4' publish_image = 'grafana/grafana-ci-deploy:1.3.3' deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image' alpine_image = 'alpine:3.15.6' From 78f03400318d62d4ca39d3047994448aeeadc12f Mon Sep 17 00:00:00 2001 From: sam boyer Date: Tue, 15 Nov 2022 08:48:31 -0500 Subject: [PATCH 245/926] plugindef: Move pluginmeta out of coremodels as standalone thema lineage (#56765) * Get pluginmeta mostly moved over to pkg/plugins/plugindef * Remove dead func * Fix up pfs, use sync.Once in plugindef * Update to latest thema * Chase Endec->Codec conversion in Thema * Comments on slash header gen; use ToSlash * Also generate JSON schema for plugindef * Generate JSON Schema as well * Fix slot loading from kindsys cue decls * Remove unused vars * skip generating plugin.schema.json for now Co-authored-by: Marcus Efraimsson --- Makefile | 1 + embed.go | 2 +- go.mod | 4 +- go.sum | 12 +- kinds/gen.go | 3 - pkg/codegen/coremodel.go | 2 +- pkg/codegen/generators.go | 19 +- pkg/codegen/jenny_tsveneerindex.go | 3 +- pkg/codegen/pluggen.go | 8 +- pkg/codegen/tmpl/kind_corestructured.tmpl | 6 +- pkg/codegen/util_go.go | 33 +++- pkg/cuectx/ctx.go | 86 +++++---- pkg/framework/coremodel/helpers.go | 107 ------------ pkg/framework/coremodel/interface.go | 29 ---- pkg/kinds/dashboard/dashboard_kind_gen.go | 6 +- pkg/kinds/playlist/playlist_kind_gen.go | 6 +- pkg/kindsys/kindcats.cue | 1 - pkg/kindsys/load.go | 2 +- pkg/{framework/coremodel => kindsys}/slot.go | 34 ++-- pkg/kindsys/slot_test.go | 29 ++++ .../coremodel => kindsys}/slots.cue | 2 +- .../testdata/disallowed-cue-import/models.cue | 2 +- pkg/plugins/pfs/pfs.go | 82 +++------ pkg/plugins/pfs/pfs_test.go | 1 + pkg/plugins/plugindef/gen.go | 132 ++++++++++++++ .../plugindef/plugindef.cue} | 10 +- pkg/plugins/plugindef/plugindef.go | 38 ++++ .../plugindef/plugindef_bindings_gen.go | 85 +++++++++ .../plugindef/plugindef_types_gen.go} | 164 ++---------------- 29 files changed, 458 insertions(+), 451 deletions(-) delete mode 100644 pkg/framework/coremodel/helpers.go delete mode 100644 pkg/framework/coremodel/interface.go rename pkg/{framework/coremodel => kindsys}/slot.go (72%) create mode 100644 pkg/kindsys/slot_test.go rename pkg/{framework/coremodel => kindsys}/slots.cue (99%) create mode 100644 pkg/plugins/plugindef/gen.go rename pkg/{coremodel/pluginmeta/coremodel.cue => plugins/plugindef/plugindef.cue} (94%) create mode 100644 pkg/plugins/plugindef/plugindef.go create mode 100644 pkg/plugins/plugindef/plugindef_bindings_gen.go rename pkg/{coremodel/pluginmeta/pluginmeta_gen.go => plugins/plugindef/plugindef_types_gen.go} (70%) diff --git a/Makefile b/Makefile index 52700ac46bb..03a2c40b48b 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,7 @@ openapi3-gen: swagger-api-spec ## Generates OpenApi 3 specs from the Swagger 2 a ##@ Building gen-cue: ## Do all CUE/Thema code generation @echo "generate code from .cue files" + go generate ./pkg/plugins/plugindef go generate ./kinds/gen.go go generate ./pkg/framework/coremodel go generate ./public/app/plugins diff --git a/embed.go b/embed.go index c1353761781..7051e0af971 100644 --- a/embed.go +++ b/embed.go @@ -6,5 +6,5 @@ import ( // CueSchemaFS embeds all schema-related CUE files in the Grafana project. // -//go:embed cue.mod/module.cue kinds/*/*.cue kinds/*/*/*.cue packages/grafana-schema/src/schema/*.cue public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json pkg/framework/coremodel/*.cue pkg/kindsys/*.cue +//go:embed cue.mod/module.cue kinds/*/*.cue kinds/*/*/*.cue packages/grafana-schema/src/schema/*.cue public/app/plugins/*/*/*.cue public/app/plugins/*/*/plugin.json pkg/kindsys/*.cue pkg/plugins/plugindef/*.cue var CueSchemaFS embed.FS diff --git a/go.mod b/go.mod index 1630089a699..9edbe559b78 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/grafana/grafana-aws-sdk v0.11.0 github.com/grafana/grafana-azure-sdk-go v1.3.1 github.com/grafana/grafana-plugin-sdk-go v0.142.0 - github.com/grafana/thema v0.0.0-20221107225215-00ad2949c7bc + github.com/grafana/thema v0.0.0-20221113112305-b441ed85a1fd github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/hashicorp/go-hclog v1.0.0 github.com/hashicorp/go-plugin v1.4.3 @@ -349,7 +349,7 @@ require ( github.com/yudai/pp v2.0.1+incompatible // indirect go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.7.0 // indirect go.opentelemetry.io/proto/otlp v0.16.0 // indirect - golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + golang.org/x/mod v0.7.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 4bd65ce99be..9abdd47c2e5 100644 --- a/go.sum +++ b/go.sum @@ -1242,7 +1242,6 @@ github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6A github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -1370,8 +1369,10 @@ github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293 github.com/grafana/prometheus-alertmanager v0.24.1-0.20221012142027-823cd9150293/go.mod h1:HVHqK+BVPa/tmL8EMhLCCrPt2a1GdJpEyxr5hgur2UI= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc h1:1PY8n+rXuBNr3r1JQhoytWDCpc+pq+BibxV0SZv+Cr4= github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4= -github.com/grafana/thema v0.0.0-20221107225215-00ad2949c7bc h1:Icv777/PBaqhLmbSBSDaajDl424cbmh5ee77Du2rUFE= -github.com/grafana/thema v0.0.0-20221107225215-00ad2949c7bc/go.mod h1:wnIJykzNiNVANl6g/Z4nkXxoMqaaH1LoG0IPNW++BEk= +github.com/grafana/thema v0.0.0-20221113034006-50fd3c0da5ce h1:N1K0WWaG0B5i/703ri0WSazQYVsCYj1mgODgElCz0o8= +github.com/grafana/thema v0.0.0-20221113034006-50fd3c0da5ce/go.mod h1:ZJHKwNE86ngdQ7edJIFHepCiIg9YP9x+YZPEm3dlkL4= +github.com/grafana/thema v0.0.0-20221113112305-b441ed85a1fd h1:y6H9I5fy4sRKf2FJ7W94YWero4mXH50Ft8NAPZ9DapQ= +github.com/grafana/thema v0.0.0-20221113112305-b441ed85a1fd/go.mod h1:ZJHKwNE86ngdQ7edJIFHepCiIg9YP9x+YZPEm3dlkL4= github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6 h1:I9dh1MXGX0wGyxdV/Sl7+ugnki4Dfsy8lv2s5Yf887o= github.com/grafana/xorm v0.8.3-0.20220614223926-2fcda7565af6/go.mod h1:ZkJLEYLoVyg7amJK/5r779bHyzs2AU8f8VMiP6BM7uY= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -1823,8 +1824,6 @@ github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA= -github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-tty v0.0.0-20180907095812-13ff1204f104/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= @@ -2756,8 +2755,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/kinds/gen.go b/kinds/gen.go index 8fd79172c7f..588fa614631 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -109,9 +109,6 @@ func main() { if err != nil { die(fmt.Errorf("core kinddirs codegen failed: %w", err)) } - // for _, f := range jfs.AsFiles() { - // fmt.Println(filepath.Join(groot, f.RelativePath)) - // } if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { if err = jfs.Verify(context.Background(), groot); err != nil { diff --git a/pkg/codegen/coremodel.go b/pkg/codegen/coremodel.go index d3b8ae92464..b0c174e4303 100644 --- a/pkg/codegen/coremodel.go +++ b/pkg/codegen/coremodel.go @@ -237,7 +237,7 @@ type tplVars struct { } func (cd *CoremodelDeclaration) GenerateTypescriptCoremodel() (*tsast.File, error) { - schv := thema.SchemaP(cd.Lineage, thema.LatestVersion(cd.Lineage)).UnwrapCUE() + schv := cd.Lineage.Latest().Underlying() tf, err := cuetsy.GenerateAST(schv, cuetsy.Config{ Export: true, diff --git a/pkg/codegen/generators.go b/pkg/codegen/generators.go index c6090dfe4f5..51d9075557d 100644 --- a/pkg/codegen/generators.go +++ b/pkg/codegen/generators.go @@ -3,6 +3,7 @@ package codegen import ( "bytes" "fmt" + "path/filepath" "github.com/grafana/codejen" "github.com/grafana/grafana/pkg/kindsys" @@ -39,12 +40,22 @@ func (decl *DeclForGen) Lineage() thema.Lineage { return decl.lin } +// SlashHeaderMapper produces a FileMapper that injects a comment header onto +// a [codejen.File] indicating the main generator that produced it (via the provided +// maingen, which should be a path) and the jenny or jennies that constructed the +// file. func SlashHeaderMapper(maingen string) codejen.FileMapper { return func(f codejen.File) (codejen.File, error) { - b := new(bytes.Buffer) - fmt.Fprintf(b, headerTmpl, maingen, f.FromString()) - fmt.Fprint(b, string(f.Data)) - f.Data = b.Bytes() + // Never inject on certain filetypes, it's never valid + switch filepath.Ext(f.RelativePath) { + case ".json", ".yml", ".yaml": + return f, nil + default: + b := new(bytes.Buffer) + fmt.Fprintf(b, headerTmpl, filepath.ToSlash(maingen), f.FromString()) + fmt.Fprint(b, string(f.Data)) + f.Data = b.Bytes() + } return f, nil } } diff --git a/pkg/codegen/jenny_tsveneerindex.go b/pkg/codegen/jenny_tsveneerindex.go index c213a20ddfd..0bb82482e5f 100644 --- a/pkg/codegen/jenny_tsveneerindex.go +++ b/pkg/codegen/jenny_tsveneerindex.go @@ -66,11 +66,10 @@ func (gen *genTSVeneerIndex) Generate(decls []*DeclForGen) (*codejen.File, error func (gen *genTSVeneerIndex) extractTSIndexVeneerElements(decl *DeclForGen, tf *ast.File) ([]ast.Decl, error) { lin := decl.Lineage() - sch := thema.SchemaP(lin, thema.LatestVersion(lin)) comm := decl.Meta.Common() // Check the root, then walk the tree - rootv := sch.UnwrapCUE() + rootv := lin.Latest().Underlying() var raw, custom, rawD, customD ast.Idents diff --git a/pkg/codegen/pluggen.go b/pkg/codegen/pluggen.go index 34beeac4b2d..0694faf8445 100644 --- a/pkg/codegen/pluggen.go +++ b/pkg/codegen/pluggen.go @@ -15,7 +15,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/grafana/cuetsy" tsast "github.com/grafana/cuetsy/ts/ast" - "github.com/grafana/grafana/pkg/framework/coremodel" + "github.com/grafana/grafana/pkg/kindsys" "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/thema" "github.com/grafana/thema/encoding/openapi" @@ -146,7 +146,7 @@ func (pt *PluginTree) GenerateTypeScriptAST() (*tsast.File, error) { // whether the slot is a grouped lineage: // https://github.com/grafana/thema/issues/62 if isGroupLineage(slotname) { - tsf, err := cuetsy.GenerateAST(sch.UnwrapCUE(), cuetsy.Config{ + tsf, err := cuetsy.GenerateAST(sch.Underlying(), cuetsy.Config{ Export: true, }) if err != nil { @@ -154,7 +154,7 @@ func (pt *PluginTree) GenerateTypeScriptAST() (*tsast.File, error) { } f.Nodes = append(f.Nodes, tsf.Nodes...) } else { - pair, err := cuetsy.GenerateSingleAST(strings.Title(lin.Name()), sch.UnwrapCUE(), cuetsy.TypeInterface) + pair, err := cuetsy.GenerateSingleAST(strings.Title(lin.Name()), sch.Underlying(), cuetsy.TypeInterface) if err != nil { return nil, fmt.Errorf("error translating %s lineage to TypeScript: %w", slotname, err) } @@ -169,7 +169,7 @@ func (pt *PluginTree) GenerateTypeScriptAST() (*tsast.File, error) { } func isGroupLineage(slotname string) bool { - sl, has := coremodel.AllSlots()[slotname] + sl, has := kindsys.AllSlots(nil)[slotname] if !has { panic("unknown slotname name: " + slotname) } diff --git a/pkg/codegen/tmpl/kind_corestructured.tmpl b/pkg/codegen/tmpl/kind_corestructured.tmpl index d617ccfd2b6..71fbac06074 100644 --- a/pkg/codegen/tmpl/kind_corestructured.tmpl +++ b/pkg/codegen/tmpl/kind_corestructured.tmpl @@ -15,7 +15,7 @@ const rootrel string = "kinds/structured/{{ .Meta.MachineName }}" // TODO standard generated docs type Kind struct { lin thema.ConvergentLineage[*{{ .Meta.Name }}] - jendec vmux.Endec + jcodec vmux.Codec valmux vmux.ValueMux[*{{ .Meta.Name }}] decl kindsys.Decl[kindsys.CoreStructuredMeta] } @@ -47,9 +47,9 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { return nil, err } - k.jendec = vmux.NewJSONEndec("{{ .Meta.MachineName }}.json") + k.jcodec = vmux.NewJSONCodec("{{ .Meta.MachineName }}.json") k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jendec) + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) return k, nil } diff --git a/pkg/codegen/util_go.go b/pkg/codegen/util_go.go index 3c8b05f3a64..b65df3ba85d 100644 --- a/pkg/codegen/util_go.go +++ b/pkg/codegen/util_go.go @@ -69,8 +69,8 @@ func postprocessGoFile(cfg genGoFile) ([]byte, error) { } type prefixmod struct { - str string - base string + prefix string + replace string rxp *regexp.Regexp rxpsuff *regexp.Regexp } @@ -80,7 +80,22 @@ type prefixmod struct { // comments in a generated Go file. func PrefixDropper(prefix string) astutil.ApplyFunc { return (&prefixmod{ - str: prefix, + prefix: prefix, + rxpsuff: regexp.MustCompile(fmt.Sprintf(`%s([a-zA-Z_]+)`, prefix)), + rxp: regexp.MustCompile(fmt.Sprintf(`%s([\s.,;-])`, prefix)), + }).applyfunc +} + +// PrefixReplacer returns an astutil.ApplyFunc that removes the provided prefix +// string when it appears as a leading sequence in type names, var names, and +// comments in a generated Go file. +// +// When an exact match for prefix is found, the provided replace string +// is substituted. +func PrefixReplacer(prefix, replace string) astutil.ApplyFunc { + return (&prefixmod{ + prefix: prefix, + replace: replace, rxpsuff: regexp.MustCompile(fmt.Sprintf(`%s([a-zA-Z_]+)`, prefix)), rxp: regexp.MustCompile(fmt.Sprintf(`%s([\s.,;-])`, prefix)), }).applyfunc @@ -113,8 +128,8 @@ func (d prefixmod) applyfunc(c *astutil.Cursor) bool { case *ast.CommentGroup: for _, c := range x.List { c.Text = d.rxpsuff.ReplaceAllString(c.Text, "$1") - if d.base != "" { - c.Text = d.rxp.ReplaceAllString(c.Text, d.base+"$1") + if d.replace != "" { + c.Text = d.rxp.ReplaceAllString(c.Text, d.replace+"$1") } } } @@ -142,9 +157,9 @@ func (d prefixmod) handleExpr(e ast.Expr) { } func (d prefixmod) do(n *ast.Ident) { - if n.Name != d.str { - n.Name = strings.TrimPrefix(n.Name, d.str) - } else if d.base != "" { - n.Name = d.base + if n.Name != d.prefix { + n.Name = strings.TrimPrefix(n.Name, d.prefix) + } else if d.replace != "" { + n.Name = d.replace } } diff --git a/pkg/cuectx/ctx.go b/pkg/cuectx/ctx.go index 7b29041eb3c..c4084fdd6f6 100644 --- a/pkg/cuectx/ctx.go +++ b/pkg/cuectx/ctx.go @@ -50,7 +50,7 @@ func GrafanaThemaRuntime() *thema.Runtime { // call it repeatedly. Most use cases should probably prefer making // their own Thema/CUE decoders. func JSONtoCUE(path string, b []byte) (cue.Value, error) { - return vmux.NewJSONEndec(path).Decode(ctx, b) + return vmux.NewJSONCodec(path).Decode(ctx, b) } // LoadGrafanaInstancesWithThema loads CUE files containing a lineage @@ -61,7 +61,7 @@ func JSONtoCUE(path string, b []byte) (cue.Value, error) { // path from the grafana root to the directory containing the lineage.cue. The // lineage.cue file must be the sole contents of the provided fs.FS. // -// More details on underlying behavior can be found in the docs for github.com/grafana/thema/load.InstancesWithThema. +// More details on underlying behavior can be found in the docs for github.com/grafana/thema/load.InstanceWithThema. // // TODO this approach is complicated and confusing, refactor to something understandable func LoadGrafanaInstancesWithThema(path string, cueFS fs.FS, rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { @@ -70,7 +70,7 @@ func LoadGrafanaInstancesWithThema(path string, cueFS fs.FS, rt *thema.Runtime, if err != nil { return nil, err } - inst, err := load.InstancesWithThema(fs, prefix) + inst, err := load.InstanceWithThema(fs, prefix) // Need to trick loading by creating the embedded file and // making it look like a module in the root dir. @@ -93,7 +93,7 @@ func LoadGrafanaInstancesWithThema(path string, cueFS fs.FS, rt *thema.Runtime, // The provided prefix should be the relative path from the grafana repository // root to the directory root of the provided inputfs. // -// The returned fs.FS is suitable for passing to a CUE loader, such as [load.InstancesWithThema]. +// The returned fs.FS is suitable for passing to a CUE loader, such as [load.InstanceWithThema]. func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) { m := fstest.MapFS{ // fstest can recognize only forward slashes. @@ -124,10 +124,9 @@ func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) { return merged_fs.NewMergedFS(m, grafana.CueSchemaFS), nil } -// BuildGrafanaInstance wraps [load.InstancesWithThema] to load a +// LoadGrafanaInstance wraps [load.InstanceWithThema] to load a // [*build.Instance] corresponding to a particular path within the -// github.com/grafana/grafana CUE module, then builds that into a [cue.Value], -// checks it for errors and returns. +// github.com/grafana/grafana CUE module. // // This allows resolution of imports within the grafana or thema CUE modules to // work correctly and consistently by relying on the embedded FS at @@ -143,7 +142,7 @@ func prefixWithGrafanaCUE(prefix string, inputfs fs.FS) (fs.FS, error) { // is the same as the parent directory name, it should be omitted. // // NOTE this function will be removed in favor of a more generic loader -func BuildGrafanaInstance(relpath string, pkg string, ctx *cue.Context, overlay fs.FS) (cue.Value, error) { +func LoadGrafanaInstance(relpath string, pkg string, overlay fs.FS) (*build.Instance, error) { // notes about how this crap needs to work // // Within grafana/grafana, need: @@ -151,41 +150,6 @@ func BuildGrafanaInstance(relpath string, pkg string, ctx *cue.Context, overlay // - has no cue.mod // - gets prefixed with the appropriate path within grafana/grafana // - and merged with all the other .cue files from grafana/grafana - if ctx == nil { - ctx = GrafanaCUEContext() - } - relpath = filepath.ToSlash(relpath) - - var v cue.Value - var f fs.FS = grafana.CueSchemaFS - var err error - if overlay != nil { - f, err = prefixWithGrafanaCUE(relpath, overlay) - if err != nil { - return v, err - } - } - - var bi *build.Instance - if pkg != "" { - bi, err = load.InstancesWithThema(f, relpath, load.Package(pkg)) - } else { - bi, err = load.InstancesWithThema(f, relpath) - } - if err != nil { - return v, err - } - - v = ctx.BuildInstance(bi) - if v.Err() != nil { - return v, fmt.Errorf("%s not a valid CUE instance: %w", relpath, v.Err()) - } - return v, nil -} - -// TODO docs -// NOTE this function will be removed in favor of a more generic loader -func LoadInstanceWithGrafana(ifs fs.FS, prefix string) (*build.Instance, error) { // notes about how this crap needs to work // // Need a prefixing instance loader that: @@ -193,6 +157,40 @@ func LoadInstanceWithGrafana(ifs fs.FS, prefix string) (*build.Instance, error) // - reconcile at most one of the provided fs with cwd // - behavior must differ depending on whether cwd is in a cue module // - behavior should(?) be controllable depending on + relpath = filepath.ToSlash(relpath) - panic("TODO") + var f fs.FS = grafana.CueSchemaFS + var err error + if overlay != nil { + f, err = prefixWithGrafanaCUE(relpath, overlay) + if err != nil { + return nil, err + } + } + + if pkg != "" { + return load.InstanceWithThema(f, relpath, load.Package(pkg)) + } + return load.InstanceWithThema(f, relpath) +} + +// BuildGrafanaInstance wraps [LoadGrafanaInstance], additionally building +// the returned [*build.Instance], if valid, into a [cue.Value] that is checked +// for errors before returning. +// +// NOTE this function will be removed in favor of a more generic loader +func BuildGrafanaInstance(ctx *cue.Context, relpath string, pkg string, overlay fs.FS) (cue.Value, error) { + bi, err := LoadGrafanaInstance(relpath, pkg, overlay) + if err != nil { + return cue.Value{}, err + } + + if ctx == nil { + ctx = GrafanaCUEContext() + } + v := ctx.BuildInstance(bi) + if v.Err() != nil { + return v, fmt.Errorf("%s not a valid CUE instance: %w", relpath, v.Err()) + } + return v, nil } diff --git a/pkg/framework/coremodel/helpers.go b/pkg/framework/coremodel/helpers.go deleted file mode 100644 index e7b47a2951c..00000000000 --- a/pkg/framework/coremodel/helpers.go +++ /dev/null @@ -1,107 +0,0 @@ -package coremodel - -import ( - "embed" - "fmt" - "io/fs" - "path/filepath" - "testing/fstest" - - "cuelang.org/go/cue" - "cuelang.org/go/cue/load" - tload "github.com/grafana/thema/load" - - "github.com/grafana/grafana/pkg/cuectx" -) - -// Embed for all framework-related CUE files in this directory -// -//go:embed *.cue -var cueFS embed.FS - -var defaultFramework cue.Value - -func init() { - var err error - defaultFramework, err = doLoadFrameworkCUE(cuectx.GrafanaCUEContext()) - if err != nil { - panic(err) - } -} - -var prefix = filepath.Join("/pkg", "framework", "coremodel") - -//nolint:nakedret -func doLoadFrameworkCUE(ctx *cue.Context) (v cue.Value, err error) { - m := make(fstest.MapFS) - - err = fs.WalkDir(cueFS, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - b, err := fs.ReadFile(cueFS, path) - if err != nil { - return err - } - m[path] = &fstest.MapFile{Data: b} - return nil - }) - if err != nil { - return - } - - over := make(map[string]load.Source) - - absolutePath := prefix - if !filepath.IsAbs(absolutePath) { - absolutePath, err = filepath.Abs(absolutePath) - if err != nil { - return - } - } - - err = tload.ToOverlay(absolutePath, m, over) - if err != nil { - return - } - - bi := load.Instances(nil, &load.Config{ - Dir: absolutePath, - Package: "coremodel", - Overlay: over, - }) - v = ctx.BuildInstance(bi[0]) - - if v.Err() != nil { - return cue.Value{}, fmt.Errorf("coremodel framework loaded cue.Value has err: %w", v.Err()) - } - - return -} - -// CUEFramework returns a cue.Value representing all the coremodel framework -// raw CUE files. -// -// For low-level use in constructing other types and APIs, while still letting -// us declare all the frameworky CUE bits in a single package. Other types and -// subpackages make the constructs in this value easy to use. -// -// The returned cue.Value is built from Grafana's standard central CUE context, -// ["github.com/grafana/grafana/pkg/cuectx".ProvideCueContext]. -func CUEFramework() cue.Value { - return defaultFramework -} - -// CUEFrameworkWithContext is the same as CUEFramework, but allows control over -// the cue.Context that's used. -// -// Prefer CUEFramework unless you understand cue.Context, and absolutely need -// this control. -func CUEFrameworkWithContext(ctx *cue.Context) cue.Value { - // Error guaranteed to be nil here because erroring would have caused init() to panic - v, _ := doLoadFrameworkCUE(ctx) // nolint:errcheck - return v -} diff --git a/pkg/framework/coremodel/interface.go b/pkg/framework/coremodel/interface.go deleted file mode 100644 index e0d64ae7763..00000000000 --- a/pkg/framework/coremodel/interface.go +++ /dev/null @@ -1,29 +0,0 @@ -package coremodel - -// Generates all code derived from coremodel Thema lineages that's used directly -// by both the frontend and backend. -//go:generate go run gen.go - -import ( - "github.com/grafana/thema" -) - -// Interface is the primary coremodel interface that must be implemented by all -// Grafana coremodels. A coremodel is the foundational, canonical schema for -// some known-at-compile-time Grafana object. -// -// Currently, all Coremodels are expressed as Thema lineages. -type Interface interface { - // Lineage should return the canonical Thema lineage for the coremodel. - Lineage() thema.Lineage - - // CurrentSchema should return the schema of the version that the Grafana backend - // is currently written against. (While Grafana can accept data from all - // older versions of the Thema schema, backend Go code is written against a - // single version for simplicity) - CurrentSchema() thema.Schema - - // GoType should return a pointer to the Go struct type that corresponds to - // the Current() schema. - GoType() interface{} -} diff --git a/pkg/kinds/dashboard/dashboard_kind_gen.go b/pkg/kinds/dashboard/dashboard_kind_gen.go index 6bdb47dacf3..ee275dc8426 100644 --- a/pkg/kinds/dashboard/dashboard_kind_gen.go +++ b/pkg/kinds/dashboard/dashboard_kind_gen.go @@ -24,7 +24,7 @@ const rootrel string = "kinds/structured/dashboard" // TODO standard generated docs type Kind struct { lin thema.ConvergentLineage[*Dashboard] - jendec vmux.Endec + jcodec vmux.Codec valmux vmux.ValueMux[*Dashboard] decl kindsys.Decl[kindsys.CoreStructuredMeta] } @@ -56,9 +56,9 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { return nil, err } - k.jendec = vmux.NewJSONEndec("dashboard.json") + k.jcodec = vmux.NewJSONCodec("dashboard.json") k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jendec) + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) return k, nil } diff --git a/pkg/kinds/playlist/playlist_kind_gen.go b/pkg/kinds/playlist/playlist_kind_gen.go index 2ae79506035..eb82358c7bb 100644 --- a/pkg/kinds/playlist/playlist_kind_gen.go +++ b/pkg/kinds/playlist/playlist_kind_gen.go @@ -24,7 +24,7 @@ const rootrel string = "kinds/structured/playlist" // TODO standard generated docs type Kind struct { lin thema.ConvergentLineage[*Playlist] - jendec vmux.Endec + jcodec vmux.Codec valmux vmux.ValueMux[*Playlist] decl kindsys.Decl[kindsys.CoreStructuredMeta] } @@ -56,9 +56,9 @@ func NewKind(rt *thema.Runtime, opts ...thema.BindOption) (*Kind, error) { return nil, err } - k.jendec = vmux.NewJSONEndec("playlist.json") + k.jcodec = vmux.NewJSONCodec("playlist.json") k.lin = tsch.ConvergentLineage() - k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jendec) + k.valmux = vmux.NewValueMux(k.lin.TypedSchema(), k.jcodec) return k, nil } diff --git a/pkg/kindsys/kindcats.cue b/pkg/kindsys/kindcats.cue index 4321b45b78b..30ab638439e 100644 --- a/pkg/kindsys/kindcats.cue +++ b/pkg/kindsys/kindcats.cue @@ -163,4 +163,3 @@ _sharedKind: { // It is required that lineage.name is the same as the [machineName]. lineage: thema.#Lineage & { name: S.machineName } } - diff --git a/pkg/kindsys/load.go b/pkg/kindsys/load.go index d8d4cda1c67..08e4b857435 100644 --- a/pkg/kindsys/load.go +++ b/pkg/kindsys/load.go @@ -227,7 +227,7 @@ func (decl *Decl[T]) Some() *SomeDecl { // For representations of core kinds that are useful in Go programs at runtime, // see ["github.com/grafana/grafana/pkg/registry/corekind"]. func LoadCoreKind[T RawMeta | CoreStructuredMeta](declpath string, ctx *cue.Context, overlay fs.FS) (*Decl[T], error) { - vk, err := cuectx.BuildGrafanaInstance(declpath, "kind", ctx, overlay) + vk, err := cuectx.BuildGrafanaInstance(ctx, declpath, "kind", overlay) if err != nil { return nil, err } diff --git a/pkg/framework/coremodel/slot.go b/pkg/kindsys/slot.go similarity index 72% rename from pkg/framework/coremodel/slot.go rename to pkg/kindsys/slot.go index 69c212b3c06..acfd07d1d79 100644 --- a/pkg/framework/coremodel/slot.go +++ b/pkg/kindsys/slot.go @@ -1,24 +1,28 @@ -package coremodel +package kindsys import ( "cuelang.org/go/cue" ) -// Slot represents one of Grafana's named Thema composition slot definitions. +// Slot represents one of Grafana's named slot definitions. +// TODO link to framework docs type Slot struct { name string raw cue.Value plugins map[string]bool } -// Name returns the name of the Slot. The name is also used as the path at which -// a Slot lineage is defined in a plugin models.cue file. +// Name returns the name of the Slot. +// +// The name is also used as the path at which a Slot lineage is defined in a +// plugin models.cue file. func (s Slot) Name() string { return s.name } -// MetaSchema returns the meta-schema that is the contract between coremodels -// that compose the Slot, and plugins that implement it. +// MetaSchema returns the meta-schema that is the contract between core or +// custom kinds that compose the meta-schema, and the plugin-declared composable +// kinds that implement the meta-schema. func (s Slot) MetaSchema() cue.Value { return s.raw } @@ -28,15 +32,15 @@ func (s Slot) MetaSchema() cue.Value { // may, whether they must produce one (second return value). // // Expected values here are those in the set of -// ["github.com/grafana/grafana/pkg/coremodel/pluginmeta".Type], though passing +// ["github.com/grafana/grafana/pkg/plugins/plugindef".Type], though passing // a string not in that set will harmlessly return {false, false}. That type is // not used here to avoid import cycles. // // Note that, at least for now, plugins are not required to provide any slot -// implementations, and do so by simply not containing a models.cue file. -// Consequently, the "must" return value here is best understood as, "IF a -// plugin provides a models.cue file, it MUST contain an implementation of this -// slot." +// implementations, and do so by simply not containing any .cue files in the +// "grafanaplugin" package. Consequently, the "must" return value is best +// understood as, "IF a plugin provides a *.cue files, it MUST contain an +// implementation of this slot." func (s Slot) ForPluginType(plugintype string) (may, must bool) { must, may = s.plugins[plugintype] return @@ -58,8 +62,12 @@ func (s Slot) IsGroup() bool { } } -func AllSlots() map[string]*Slot { - fw := CUEFramework() +// AllSlots returns a map of all [Slot]s defined in the Grafana kindsys +// framework. +// +// TODO cache this for core context +func AllSlots(ctx *cue.Context) map[string]*Slot { + fw := CUEFramework(ctx) slots := make(map[string]*Slot) // Ignore err, can only happen if we change structure of fw files, and all we'd diff --git a/pkg/kindsys/slot_test.go b/pkg/kindsys/slot_test.go new file mode 100644 index 00000000000..a4a17c8ae45 --- /dev/null +++ b/pkg/kindsys/slot_test.go @@ -0,0 +1,29 @@ +package kindsys + +import ( + "sort" + "testing" + + "cuelang.org/go/cue/cuecontext" + "github.com/stretchr/testify/require" +) + +// This is a brick-dumb test that just ensures slots are being loaded correctly +// from their declarations in .cue files. +// +// If this test fails, it's either because: +// - They're not being loaded correctly - there's a bug in kindsys somewhere, fix it +// - The set of slots names has been modified - update the static list here +func TestSlotsAreLoaded(t *testing.T) { + slots := []string{"Panel", "Query", "DSOptions"} + all := AllSlots(cuecontext.New()) + var loadedSlots []string + for k := range all { + loadedSlots = append(loadedSlots, k) + } + + sort.Strings(slots) + sort.Strings(loadedSlots) + + require.Equal(t, slots, loadedSlots, "slots loaded from cue differs from fixture set - either a bug or fixture needs updating") +} diff --git a/pkg/framework/coremodel/slots.cue b/pkg/kindsys/slots.cue similarity index 99% rename from pkg/framework/coremodel/slots.cue rename to pkg/kindsys/slots.cue index 6de83523482..2d4976ff274 100644 --- a/pkg/framework/coremodel/slots.cue +++ b/pkg/kindsys/slots.cue @@ -1,4 +1,4 @@ -package coremodel +package kindsys // The slots named and specified in this file are meta-schemas that act as a // shared contract between Grafana plugins (producers) and coremodel types diff --git a/pkg/plugins/manager/testdata/disallowed-cue-import/models.cue b/pkg/plugins/manager/testdata/disallowed-cue-import/models.cue index 7094c2fb31f..b123e68c276 100644 --- a/pkg/plugins/manager/testdata/disallowed-cue-import/models.cue +++ b/pkg/plugins/manager/testdata/disallowed-cue-import/models.cue @@ -2,7 +2,7 @@ package grafanaplugin import ( "github.com/grafana/thema" - "github.com/grafana/grafana/pkg/framework/coremodel" + "github.com/grafana/grafana/kinds/structured/dashboard:kind" ) _dummy: coremodel.slots diff --git a/pkg/plugins/pfs/pfs.go b/pkg/plugins/pfs/pfs.go index d176c7dff3e..04682f07380 100644 --- a/pkg/plugins/pfs/pfs.go +++ b/pkg/plugins/pfs/pfs.go @@ -5,16 +5,14 @@ import ( "io/fs" "sort" "strings" - "sync" "cuelang.org/go/cue" "cuelang.org/go/cue/ast" "cuelang.org/go/cue/errors" "cuelang.org/go/cue/parser" "github.com/grafana/grafana" - "github.com/grafana/grafana/pkg/coremodel/pluginmeta" - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" + "github.com/grafana/grafana/pkg/kindsys" + "github.com/grafana/grafana/pkg/plugins/plugindef" "github.com/grafana/thema" "github.com/grafana/thema/load" "github.com/grafana/thema/vmux" @@ -23,6 +21,8 @@ import ( // PermittedCUEImports returns the list of packages that may be imported in a // plugin models.cue file. +// +// TODO probably move this into kindsys func PermittedCUEImports() []string { return []string{ "github.com/grafana/thema", @@ -41,20 +41,13 @@ func importAllowed(path string) bool { var allowedImportsStr string -// Name expected to be used for all models.cue files in Grafana plugins -const pkgname = "grafanaplugin" - type slotandname struct { name string - slot *coremodel.Slot + slot *kindsys.Slot } var allslots []slotandname -// TODO re-enable after go1.18 -var tsch thema.TypedSchema[*pluginmeta.Model] -var plugmux vmux.ValueMux[*pluginmeta.Model] - func init() { var all []string for _, im := range PermittedCUEImports() { @@ -62,7 +55,7 @@ func init() { } allowedImportsStr = strings.Join(all, "\n") - for n, s := range coremodel.AllSlots() { + for n, s := range kindsys.AllSlots(nil) { allslots = append(allslots, slotandname{ name: n, slot: s, @@ -74,42 +67,6 @@ func init() { }) } -var muxonce sync.Once - -// This used to be in init(), but that creates a risk for codegen. -// -// thema.BindType ensures that Go type and Thema schema are aligned. If we were -// to call it during init(), then the code generator that fixes misalignments -// between those two could trigger it if it depends on this package. That would -// mean that schema changes to pluginmeta get caught in a loop where the codegen -// process can't heal itself. -// -// In theory, that dependency shouldn't exist - this package should only be -// imported for plugin codegen, which should all happen after coremodel codegen. -// But in practice, it might exist. And it's really brittle and confusing to -// fix if that does happen. -// -// Better to be resilient to the possibility instead. So, this is a standalone function, -// called as needed to get our muxer, and internally relies on a sync.Once to avoid -// repeated processing of thema.BindType. -// TODO mux loading is easily generalizable in pkg/f/coremodel, shouldn't need one-off -func loadMux() (thema.TypedSchema[*pluginmeta.Model], vmux.ValueMux[*pluginmeta.Model]) { - muxonce.Do(func() { - var err error - t := new(pluginmeta.Model) - pm, err := pluginmeta.New(cuectx.GrafanaThemaRuntime()) - if err != nil { - panic(err) - } - tsch, err = thema.BindType[*pluginmeta.Model](pm.CurrentSchema(), t) - if err != nil { - panic(err) - } - plugmux = vmux.NewValueMux(tsch, vmux.NewJSONEndec("plugin.json")) - }) - return tsch, plugmux -} - // Tree represents the contents of a plugin filesystem tree. type Tree struct { raw fs.FS @@ -154,7 +111,7 @@ func (tl TreeList) LineagesForSlot(slotname string) map[string]thema.Lineage { // PluginInfo represents everything knowable about a single plugin from static // analysis of its filesystem tree contents. type PluginInfo struct { - meta pluginmeta.Model + meta plugindef.PluginDef slotimpls map[string]thema.Lineage imports []*ast.ImportSpec } @@ -174,7 +131,7 @@ func (pi PluginInfo) SlotImplementations() map[string]thema.Lineage { } // Meta returns the metadata declared in the plugin's plugin.json file. -func (pi PluginInfo) Meta() pluginmeta.Model { +func (pi PluginInfo) Meta() plugindef.PluginDef { return pi.meta } @@ -183,12 +140,21 @@ func (pi PluginInfo) Meta() pluginmeta.Model { // // It does not descend into subdirectories to search for additional plugin.json // files. +// +// Calling this with a nil thema.Runtime will take advantage of memoization. +// Prefer this approach unless a different thema.Runtime is specifically +// required. +// // TODO no descent is ok for core plugins, but won't cut it in general func ParsePluginFS(f fs.FS, rt *thema.Runtime) (*Tree, error) { if f == nil { return nil, ErrEmptyFS } - _, mux := loadMux() + lin, err := plugindef.Lineage(rt) + if err != nil { + panic(fmt.Sprintf("plugindef lineage is invalid or broken, needs dev attention: %s", err)) + } + mux := vmux.NewValueMux(lin.TypedSchema(), vmux.NewJSONCodec("plugin.json")) ctx := rt.Context() b, err := fs.ReadFile(f, "plugin.json") @@ -207,13 +173,11 @@ func ParsePluginFS(f fs.FS, rt *thema.Runtime) (*Tree, error) { } r := &tree.rootinfo - // Pass the raw bytes into the muxer, get the populated Model type out that we want. - // TODO stop ignoring second return. (for now, lacunas are a WIP and can't occur until there's >1 schema in the pluginmeta lineage) - // metaany, _, err := mux(b) + // Pass the raw bytes into the muxer, get the populated PluginDef type out that we want. + // TODO stop ignoring second return. (for now, lacunas are a WIP and can't occur until there's >1 schema in the plugindef lineage) pmeta, _, err := mux(b) if err != nil { // TODO more nuanced error handling by class of Thema failure - // return nil, fmt.Errorf("plugin.json was invalid: %w", err) return nil, ewrap(err, ErrInvalidRootFile) } r.meta = *pmeta @@ -231,9 +195,9 @@ func ParsePluginFS(f fs.FS, rt *thema.Runtime) (*Tree, error) { mfs := merged_fs.NewMergedFS(f, grafana.CueSchemaFS) - // Note that this actually will load any .cue files in the fs.FS root dir in the pkgname. + // Note that this actually will load any .cue files in the fs.FS root dir in the plugindef.PkgName. // That's...maybe good? But not what it says on the tin - bi, err := load.InstancesWithThema(mfs, "", load.Package(pkgname)) + bi, err := load.InstanceWithThema(mfs, "", load.Package(plugindef.PkgName)) if err != nil { return nil, fmt.Errorf("loading models.cue failed: %w", err) } @@ -267,7 +231,7 @@ func ParsePluginFS(f fs.FS, rt *thema.Runtime) (*Tree, error) { return tree, nil } -func bindSlotLineage(v cue.Value, s *coremodel.Slot, meta pluginmeta.Model, rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { +func bindSlotLineage(v cue.Value, s *kindsys.Slot, meta plugindef.PluginDef, rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { accept, required := s.ForPluginType(string(meta.Type)) exists := v.Exists() diff --git a/pkg/plugins/pfs/pfs_test.go b/pkg/plugins/pfs/pfs_test.go index 93755700c68..eed41673925 100644 --- a/pkg/plugins/pfs/pfs_test.go +++ b/pkg/plugins/pfs/pfs_test.go @@ -172,6 +172,7 @@ func TestParseTreeTestdata(t *testing.T) { if tst.err == nil { require.NoError(t, err, "unexpected error while parsing plugin tree") } else { + require.Error(t, err) require.ErrorIs(t, err, tst.err, "unexpected error type while parsing plugin tree") return } diff --git a/pkg/plugins/plugindef/gen.go b/pkg/plugins/plugindef/gen.go new file mode 100644 index 00000000000..07354ec2c46 --- /dev/null +++ b/pkg/plugins/plugindef/gen.go @@ -0,0 +1,132 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go/ast" + "os" + "path/filepath" + "strings" + + "cuelang.org/go/cue/cuecontext" + "github.com/grafana/codejen" + "github.com/grafana/grafana/pkg/codegen" + "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/thema" + "github.com/grafana/thema/encoding/gocode" + "github.com/grafana/thema/encoding/jsonschema" + "golang.org/x/tools/go/ast/astutil" +) + +var dirPlugindef = filepath.Join("pkg", "plugins", "plugindef") + +// main generator for plugindef. plugindef isn't a kind, so it has its own +// one-off main generator. +func main() { + v := elsedie(cuectx.BuildGrafanaInstance(nil, dirPlugindef, "", nil))("could not load plugindef cue package") + + lin := elsedie(thema.BindLineage(v, cuectx.GrafanaThemaRuntime()))("plugindef lineage is invalid") + + jl := &codejen.JennyList[thema.Lineage]{} + jl.AppendOneToOne(&jennytypego{}, &jennybindgo{}) + jl.AddPostprocessors(codegen.SlashHeaderMapper(filepath.Join(dirPlugindef, "gen.go"))) + + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "could not get working directory: %s", err) + os.Exit(1) + } + grootp := strings.Split(cwd, string(os.PathSeparator)) + groot := filepath.Join(string(os.PathSeparator), filepath.Join(grootp[:len(grootp)-3]...)) + + jfs := elsedie(jl.GenerateFS([]thema.Lineage{lin}))("plugindef jenny pipeline failed") + if _, set := os.LookupEnv("CODEGEN_VERIFY"); set { + if err := jfs.Verify(context.Background(), groot); err != nil { + die(fmt.Errorf("generated code is out of sync with inputs:\n%s\nrun `make gen-cue` to regenerate", err)) + } + } else if err := jfs.Write(context.Background(), groot); err != nil { + die(fmt.Errorf("error while writing generated code to disk:\n%s", err)) + } +} + +// one-off jenny for plugindef go types +type jennytypego struct{} + +func (j *jennytypego) JennyName() string { + return "PluginGoTypes" +} + +func (j *jennytypego) Generate(lin thema.Lineage) (*codejen.File, error) { + b, err := gocode.GenerateTypesOpenAPI(lin.Latest(), &gocode.TypeConfigOpenAPI{ + ApplyFuncs: []astutil.ApplyFunc{ + codegen.PrefixReplacer("Plugindef", "PluginDef"), + }, + }) + if err != nil { + return nil, err + } + return codejen.NewFile(filepath.Join(dirPlugindef, "plugindef_types_gen.go"), b, j), nil +} + +// one-off jenny for plugindef go bindings +type jennybindgo struct{} + +func (j *jennybindgo) JennyName() string { + return "PluginGoBindings" +} + +func (j *jennybindgo) Generate(lin thema.Lineage) (*codejen.File, error) { + b, err := gocode.GenerateLineageBinding(lin, &gocode.BindingConfig{ + TitleName: "PluginDef", + Assignee: ast.NewIdent("*PluginDef"), + PrivateFactory: true, + }) + if err != nil { + return nil, err + } + return codejen.NewFile(filepath.Join(dirPlugindef, "plugindef_bindings_gen.go"), b, j), nil +} + +// one-off jenny for plugindef json schema generator +type jennyjschema struct{} + +func (j *jennyjschema) JennyName() string { + return "PluginJSONSchema" +} + +func (j *jennyjschema) Generate(lin thema.Lineage) (*codejen.File, error) { + f, err := jsonschema.GenerateSchema(lin.Latest()) + if err != nil { + return nil, err + } + + b, _ := cuecontext.New().BuildFile(f).MarshalJSON() + nb := new(bytes.Buffer) + die(json.Indent(nb, b, "", " ")) + return codejen.NewFile(filepath.FromSlash("docs/sources/developers/plugins/plugin.schema.json"), nb.Bytes(), j), nil +} + +func elsedie[T any](t T, err error) func(msg string) T { + if err != nil { + return func(msg string) T { + fmt.Fprintf(os.Stderr, "%s: %s\n", msg, err) + os.Exit(1) + return t + } + } + return func(msg string) T { + return t + } +} + +func die(err error) { + if err != nil { + fmt.Fprint(os.Stderr, err, "\n") + os.Exit(1) + } +} diff --git a/pkg/coremodel/pluginmeta/coremodel.cue b/pkg/plugins/plugindef/plugindef.cue similarity index 94% rename from pkg/coremodel/pluginmeta/coremodel.cue rename to pkg/plugins/plugindef/plugindef.cue index 15a51a69197..555f78531c8 100644 --- a/pkg/coremodel/pluginmeta/coremodel.cue +++ b/pkg/plugins/plugindef/plugindef.cue @@ -1,4 +1,4 @@ -package pluginmeta +package plugindef import ( "strings" @@ -7,7 +7,7 @@ import ( ) thema.#Lineage -name: "pluginmeta" +name: "plugindef" seqs: [ { schemas: [ @@ -16,7 +16,7 @@ seqs: [ // grafana.com, then the plugin id has to follow the naming // conventions. id: string & strings.MinRunes(1) - id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|heatmap-old|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$" + id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|heatmap-old|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$" // The set of all plugin types. This hidden field exists solely // so that the set can be string-interpolated into other fields. @@ -132,7 +132,7 @@ seqs: [ autoEnabled?: bool // Optional list of RBAC RoleRegistrations. - // Describes and organizes the default permissions associated with any of the Grafana basic roles, + // Describes and organizes the default permissions associated with any of the Grafana basic roles, // which characterizes what viewers, editors, admins, or grafana admins can do on the plugin. // The Admin basic role inherits its default permissions from the Editor basic role which in turn // inherits them from the Viewer basic role. @@ -167,7 +167,7 @@ seqs: [ // scope. // Example: action: 'test-app.schedules:read', scope: 'test-app.schedules:*' #Permission: { - action: string, + action: string scope?: string } diff --git a/pkg/plugins/plugindef/plugindef.go b/pkg/plugins/plugindef/plugindef.go new file mode 100644 index 00000000000..ae36dd64244 --- /dev/null +++ b/pkg/plugins/plugindef/plugindef.go @@ -0,0 +1,38 @@ +package plugindef + +import ( + "sync" + + "cuelang.org/go/cue/build" + "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/thema" +) + +//go:generate go run gen.go + +// PkgName is the name of the CUE package that Grafana will load when looking +// for kind declarations by a Grafana plugin. +const PkgName = "grafanaplugin" + +func loadInstanceForplugindef() (*build.Instance, error) { + return cuectx.LoadGrafanaInstance("pkg/plugins/plugindef", "", nil) +} + +var linonce sync.Once +var pdlin thema.ConvergentLineage[*PluginDef] +var pdlinerr error + +// Lineage returns the [thema.ConvergentLineage] for plugindef, the canonical +// specification for Grafana plugin.json files. +// +// Unless a custom thema.Runtime is specifically needed, prefer calling this with +// nil, as a cached lineage will be returned. +func Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.ConvergentLineage[*PluginDef], error) { + if len(opts) == 0 && (rt == nil || rt == cuectx.GrafanaThemaRuntime()) { + linonce.Do(func() { + pdlin, pdlinerr = doLineage(rt) + }) + return pdlin, pdlinerr + } + return doLineage(rt, opts...) +} diff --git a/pkg/plugins/plugindef/plugindef_bindings_gen.go b/pkg/plugins/plugindef/plugindef_bindings_gen.go new file mode 100644 index 00000000000..8551b65269e --- /dev/null +++ b/pkg/plugins/plugindef/plugindef_bindings_gen.go @@ -0,0 +1,85 @@ +// THIS FILE IS GENERATED. EDITING IS FUTILE. +// +// Generated by: +// pkg/plugins/plugindef/gen.go +// Using jennies: +// PluginGoBindings +// +// Run 'make gen-cue' from repository root to regenerate. + +package plugindef + +import ( + "cuelang.org/go/cue/build" + "github.com/grafana/thema" +) + +// doLineage returns a [thema.ConvergentLineage] for the 'plugindef' Thema lineage. +// +// The lineage is the canonical specification of plugindef. It contains all +// schema versions that have ever existed for plugindef, and the lenses that +// allow valid instances of one schema in the lineage to be translated to +// another schema in the lineage. +// +// As a [thema.ConvergentLineage], the returned lineage has one primary schema, 0.0, +// which is [thema.AssignableTo] [*PluginDef], the lineage's parameterized type. +// +// This function will return an error if the [Thema invariants] are not met by +// the underlying lineage declaration in CUE, or if [*PluginDef] is not +// [thema.AssignableTo] the 0.0 schema. +// +// [Thema's general invariants]: https://github.com/grafana/thema/blob/main/docs/invariants.md +func doLineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.ConvergentLineage[*PluginDef], error) { + lin, err := baseLineage(rt, opts...) + if err != nil { + return nil, err + } + + sch := thema.SchemaP(lin, thema.SV(0, 0)) + typ := new(PluginDef) + tsch, err := thema.BindType(sch, typ) + if err != nil { + // This will error out if the 0.0 schema isn't assignable to + // *PluginDef. If Thema also generates that type, this should be unreachable, + // barring a critical bug in Thema's Go generator. + return nil, err + } + return tsch.ConvergentLineage(), nil +} + +func baseLineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { + // First, we must get the bytes of the .cue file(s) in which the "plugindef" lineage + // is declared, and load them into a + // "cuelang.org/go/cue/build".Instance. + // + // For most Thema-based development workflows, these bytes should come from an embed.FS. + // This ensures Go is always compiled with the current state of the .cue files. + var inst *build.Instance + var err error + + // loadInstanceForplugindef must be manually implemented in another file in this + // Go package. + inst, err = loadInstanceForplugindef() + if err != nil { + // Errors at this point indicate a problem with basic loading of .cue file bytes, + // which typically means the code generator was misconfigured and a path input + // is incorrect. + return nil, err + } + + raw := rt.Context().BuildInstance(inst) + + // An error returned from thema.BindLineage indicates one of the following: + // - The parsed path does not exist in the loaded CUE file (["github.com/grafana/thema/errors".ErrValueNotExist]) + // - The value at the parsed path exists, but does not appear to be a Thema + // lineage (["github.com/grafana/thema/errors".ErrValueNotALineage]) + // - The value at the parsed path exists and is a lineage (["github.com/grafana/thema/errors".ErrInvalidLineage]), + // but is invalid due to the violation of some general Thema invariant - + // for example, declared schemas don't follow backwards compatibility rules, + // lenses are incomplete. + return thema.BindLineage(raw, rt) +} + +// type guards +var _ thema.ConvergentLineageFactory[*PluginDef] = doLineage +var _ thema.LineageFactory = baseLineage diff --git a/pkg/coremodel/pluginmeta/pluginmeta_gen.go b/pkg/plugins/plugindef/plugindef_types_gen.go similarity index 70% rename from pkg/coremodel/pluginmeta/pluginmeta_gen.go rename to pkg/plugins/plugindef/plugindef_types_gen.go index 7de04ea12da..b6532b774ea 100644 --- a/pkg/coremodel/pluginmeta/pluginmeta_gen.go +++ b/pkg/plugins/plugindef/plugindef_types_gen.go @@ -1,21 +1,13 @@ -// This file is autogenerated. DO NOT EDIT. +// THIS FILE IS GENERATED. EDITING IS FUTILE. // -// Generated by pkg/framework/coremodel/gen.go +// Generated by: +// pkg/plugins/plugindef/gen.go +// Using jennies: +// PluginGoTypes // -// Derived from the Thema lineage declared in pkg/coremodel/pluginmeta/coremodel.cue -// -// Run `make gen-cue` from repository root to regenerate. +// Run 'make gen-cue' from repository root to regenerate. -package pluginmeta - -import ( - "embed" - "path/filepath" - - "github.com/grafana/grafana/pkg/cuectx" - "github.com/grafana/grafana/pkg/framework/coremodel" - "github.com/grafana/thema" -) +package plugindef // Defines values for Category. const ( @@ -117,11 +109,8 @@ const ( RoleRegistrationGrantsViewer RoleRegistrationGrants = "Viewer" ) -// Model is the Go representation of a pluginmeta. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. -type Model struct { +// PluginDef defines model for plugindef. +type PluginDef struct { // For data source plugins, if the plugin supports alerting. Alerting *bool `json:"alerting,omitempty"` @@ -308,30 +297,18 @@ type Model struct { } // Plugin category used on the Add data source page. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Category string // type indicates which type of Grafana plugin this is, of the defined // set of Grafana plugin types. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Type string // BasicRole is a Grafana basic role, which can be 'Viewer', 'Editor', 'Admin' or 'Grafana Admin'. // With RBAC, the Admin basic role inherits its default permissions from the Editor basic role which // in turn inherits them from the Viewer basic role. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type BasicRole string -// BuildInfo is the Go representation of a pluginmeta.BuildInfo. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// BuildInfo defines model for plugindef.BuildInfo. type BuildInfo struct { // Git branch the plugin was built from. Branch *string `json:"branch,omitempty"` @@ -348,10 +325,7 @@ type BuildInfo struct { Time *int64 `json:"time,omitempty"` } -// Dependencies is the Go representation of a pluginmeta.Dependencies. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// Dependencies defines model for plugindef.Dependencies. type Dependencies struct { // Required Grafana version for this plugin. Validated using // https://github.com/npm/node-semver. @@ -369,9 +343,6 @@ type Dependencies struct { // Dependency describes another plugin on which a plugin depends. // The id refers to the plugin package identifier, as given on // the grafana.com plugin marketplace. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Dependency struct { Id string `json:"id"` Name string `json:"name"` @@ -379,26 +350,17 @@ type Dependency struct { Version string `json:"version"` } -// DependencyType is the Go representation of a Dependency.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// DependencyType defines model for Dependency.Type. type DependencyType string // Header describes an HTTP header that is forwarded with a proxied request for // a plugin route. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Header struct { Content string `json:"content"` Name string `json:"name"` } // A resource to be included in a plugin. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Include struct { // Add the include to the side menu. AddToNav *bool `json:"addToNav,omitempty"` @@ -424,23 +386,14 @@ type Include struct { Uid *string `json:"uid,omitempty"` } -// IncludeRole is the Go representation of a Include.Role. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// IncludeRole defines model for Include.Role. type IncludeRole string -// IncludeType is the Go representation of a Include.Type. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// IncludeType defines model for Include.Type. type IncludeType string // Metadata about a Grafana plugin. Some fields are used on the plugins // page in Grafana and others on grafana.com, if the plugin is published. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Info struct { // Information about the plugin author. Author *struct { @@ -497,9 +450,6 @@ type Info struct { // TODO docs // TODO should this really be separate from TokenAuth? -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type JWTTokenAuth struct { // Parameters for the JWT token authentication request. Params map[string]interface{} `json:"params"` @@ -515,26 +465,17 @@ type JWTTokenAuth struct { // Permission describes an RBAC permission on the plugin. A permission has an action and an option // scope. // Example: action: 'test-app.schedules:read', scope: 'test-app.schedules:*' -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Permission struct { Action string `json:"action"` Scope *string `json:"scope,omitempty"` } // ReleaseState indicates release maturity state of a plugin. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type ReleaseState string // Role describes an RBAC role which allows grouping multiple related permissions on the plugin, // each of which has an action and an optional scope. // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Role struct { Description string `json:"description"` Name string `json:"name"` @@ -549,9 +490,6 @@ type Role struct { // will get them by default. // Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin // which will be granted to Admins by default. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type RoleRegistration struct { // Default assignment of the role to Grafana basic roles (Viewer, Editor, Admin, Grafana Admin) // The Admin basic role inherits its default permissions from the Editor basic role which in turn @@ -569,19 +507,13 @@ type RoleRegistration struct { } `json:"role"` } -// RoleRegistrationGrants is the Go representation of a RoleRegistration.Grants. -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. +// RoleRegistrationGrants defines model for RoleRegistration.Grants. type RoleRegistrationGrants string // A proxy route used in datasource plugins for plugin authentication // and adding headers to HTTP requests made by the plugin. // For more information, refer to [Authentication for data source // plugins](https://grafana.com/docs/grafana/latest/developers/plugins/authentication/). -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type Route struct { // For data source plugins. Route headers set the body content and // length to the proxied request. @@ -616,9 +548,6 @@ type Route struct { } // TODO docs -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type TokenAuth struct { // Parameters for the token authentication request. Params map[string]interface{} `json:"params"` @@ -633,70 +562,7 @@ type TokenAuth struct { // URLParam describes query string parameters for // a url in a plugin route -// -// THIS TYPE IS INTENDED FOR INTERNAL USE BY THE GRAFANA BACKEND, AND IS SUBJECT TO BREAKING CHANGES. -// Equivalent Go types at stable import paths are provided in https://github.com/grafana/grok. type URLParam struct { Content string `json:"content"` Name string `json:"name"` } - -//go:embed coremodel.cue -var cueFS embed.FS - -// The current version of the coremodel schema, as declared in coremodel.cue. -// This version determines what schema version is returned from [Coremodel.CurrentSchema], -// and which schema version is used for code generation within the grafana/grafana repository. -// -// The code generator ensures that this is always the latest Thema schema version. -var currentVersion = thema.SV(0, 0) - -// Lineage returns the Thema lineage representing a Grafana pluginmeta. -// -// The lineage is the canonical specification of the current pluginmeta schema, -// all prior schema versions, and the mappings that allow migration between -// schema versions. -func Lineage(rt *thema.Runtime, opts ...thema.BindOption) (thema.Lineage, error) { - return cuectx.LoadGrafanaInstancesWithThema(filepath.Join("pkg", "coremodel", "pluginmeta"), cueFS, rt, opts...) -} - -var _ thema.LineageFactory = Lineage -var _ coremodel.Interface = &Coremodel{} - -// Coremodel contains the foundational schema declaration for pluginmetas. -// It implements coremodel.Interface. -type Coremodel struct { - lin thema.Lineage -} - -// Lineage returns the canonical pluginmeta Lineage. -func (c *Coremodel) Lineage() thema.Lineage { - return c.lin -} - -// CurrentSchema returns the current (latest) pluginmeta Thema schema. -func (c *Coremodel) CurrentSchema() thema.Schema { - return thema.SchemaP(c.lin, currentVersion) -} - -// GoType returns a pointer to an empty Go struct that corresponds to -// the current Thema schema. -func (c *Coremodel) GoType() interface{} { - return &Model{} -} - -// New returns a new instance of the pluginmeta coremodel. -// -// Note that this function does not cache, and initially loading a Thema lineage -// can be expensive. As such, the Grafana backend should prefer to access this -// coremodel through a registry (pkg/framework/coremodel/registry), which does cache. -func New(rt *thema.Runtime) (*Coremodel, error) { - lin, err := Lineage(rt) - if err != nil { - return nil, err - } - - return &Coremodel{ - lin: lin, - }, nil -} From 2055d922f39c1b9ff1741aa037fbf27c98fab65b Mon Sep 17 00:00:00 2001 From: Kristina Date: Tue, 15 Nov 2022 09:10:05 -0600 Subject: [PATCH 246/926] Refactor SplitPaneWrapper to be more centralized component, refactor PanelEditor to use it (#58380) * Move layout to paneleditor, make SplitPaneWrapper more generic * Read/write the size ratio in local storage * Add min height to enable scrollbar * Enable show/hide panel options * Change back variable name --- .../SplitPaneWrapper/SplitPaneWrapper.tsx | 106 +++++------------- .../components/PanelEditor/PanelEditor.tsx | 48 ++++++-- 2 files changed, 67 insertions(+), 87 deletions(-) diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 7724b1cbaf2..51d1786d274 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -1,28 +1,23 @@ import { css, cx } from '@emotion/css'; -import React, { createRef, MutableRefObject, PureComponent, ReactNode } from 'react'; -import SplitPane from 'react-split-pane'; +import React, { createRef, MutableRefObject, PureComponent } from 'react'; +import SplitPane, { Split } from 'react-split-pane'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from 'app/core/config'; -enum Pane { - Right, - Top, -} - interface Props { - leftPaneComponents: ReactNode[] | ReactNode; - rightPaneComponents: ReactNode; - uiState: { topPaneSize: number; rightPaneSize: number }; - rightPaneVisible?: boolean; - updateUiState: (uiState: { topPaneSize?: number; rightPaneSize?: number }) => void; + splitOrientation?: Split; + paneSize: number; + splitVisible?: boolean; + maxSize?: number; + primary?: 'first' | 'second'; + onDragFinished?: (size?: number) => void; + secondaryPaneStyle?: React.CSSProperties; } export class SplitPaneWrapper extends PureComponent { + //requestAnimationFrame reference rafToken: MutableRefObject = createRef(); - static defaultProps = { - rightPaneVisible: true, - }; componentDidMount() { window.addEventListener('resize', this.updateSplitPaneSize); @@ -41,86 +36,41 @@ export class SplitPaneWrapper extends PureComponent { }); }; - onDragFinished = (pane: Pane, size?: number) => { + onDragFinished = (size?: number) => { document.body.style.cursor = 'auto'; - // When the drag handle is just clicked size is undefined - if (!size) { - return; - } - - const { updateUiState } = this.props; - if (pane === Pane.Top) { - updateUiState({ - topPaneSize: size / window.innerHeight, - }); - } else { - updateUiState({ - rightPaneSize: size / window.innerWidth, - }); + if (this.props.onDragFinished && size !== undefined) { + this.props.onDragFinished(size); } }; onDragStarted = () => { - document.body.style.cursor = 'row-resize'; + document.body.style.cursor = this.props.splitOrientation === 'horizontal' ? 'row-resize' : 'col-resize'; }; - renderHorizontalSplit() { - const { leftPaneComponents, uiState } = this.props; - const styles = getStyles(config.theme2); - const topPaneSize = uiState.topPaneSize >= 1 ? uiState.topPaneSize : uiState.topPaneSize * window.innerHeight; - - /* - Guesstimate the height of the browser window minus - panel toolbar and editor toolbar (~120px). This is to prevent resizing - the preview window beyond the browser window. - */ - - if (Array.isArray(leftPaneComponents)) { - return ( - this.onDragFinished(Pane.Top, size)} - > - {leftPaneComponents} - - ); - } - - return
{leftPaneComponents}
; - } - render() { - const { rightPaneVisible, rightPaneComponents, uiState } = this.props; + const { paneSize, splitOrientation, maxSize, primary, secondaryPaneStyle } = this.props; // Limit options pane width to 90% of screen. const styles = getStyles(config.theme2); // Need to handle when width is relative. ie a percentage of the viewport - const rightPaneSize = - uiState.rightPaneSize <= 1 ? uiState.rightPaneSize * window.innerWidth : uiState.rightPaneSize; - - if (!rightPaneVisible) { - return this.renderHorizontalSplit(); - } + const paneSizePx = + paneSize <= 1 + ? paneSize * (splitOrientation === 'horizontal' ? window.innerHeight : window.innerWidth) + : paneSize; return ( (document.body.style.cursor = 'col-resize')} - onDragFinished={(size) => this.onDragFinished(Pane.Right, size)} + split={splitOrientation} + maxSize={maxSize} + size={paneSizePx} + primary={primary} + resizerClassName={splitOrientation === 'horizontal' ? styles.resizerH : styles.resizerV} + onDragStarted={() => this.onDragStarted()} + onDragFinished={(size) => this.onDragFinished(size)} + pane2Style={secondaryPaneStyle} > - {this.renderHorizontalSplit()} - {rightPaneComponents} + {this.props.children} ); } diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 0ff0ddc03cf..d92b0bd48ab 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -47,7 +47,7 @@ import { PanelEditorTableView } from './PanelEditorTableView'; import { PanelEditorTabs } from './PanelEditorTabs'; import { VisualizationButton } from './VisualizationButton'; import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions'; -import { toggleTableView } from './state/reducers'; +import { PanelEditorUIState, toggleTableView } from './state/reducers'; import { getPanelEditorTabs } from './state/selectors'; import { DisplayMode, displayModes, PanelEditorTab } from './types'; import { calculatePanelSize } from './utils'; @@ -438,8 +438,27 @@ export class PanelEditorUnconnected extends PureComponent { ); } + renderHorizontalSplit(uiState: PanelEditorUIState, styles: EditorStyles) { + return ( + { + if (size) { + updatePanelEditorUIState({ topPaneSize: size / window.innerHeight }); + } + }} + > + {this.renderPanelAndEditor(styles)} + + ); + } + render() { - const { initDone, updatePanelEditorUIState, uiState, theme, sectionNav, pageNav, className } = this.props; + const { initDone, uiState, theme, sectionNav, pageNav, className, updatePanelEditorUIState } = this.props; const styles = getStyles(theme, this.props); if (!initDone) { @@ -457,13 +476,24 @@ export class PanelEditorUnconnected extends PureComponent { >
- + {!uiState.isPanelOptionsVisible ? ( + this.renderHorizontalSplit(uiState, styles) + ) : ( + { + if (size) { + updatePanelEditorUIState({ rightPaneSize: size / window.innerWidth }); + } + }} + > + {this.renderHorizontalSplit(uiState, styles)} + {this.renderOptionsPane()} + + )}
{this.state.showSaveLibraryPanelModal && ( Date: Tue, 15 Nov 2022 17:30:33 +0100 Subject: [PATCH 247/926] Internationalization: Translate VariableInput and VariableOptions components (#58748) --- .../variables/pickers/shared/VariableInput.tsx | 4 +++- .../variables/pickers/shared/VariableLink.tsx | 9 ++++++++- .../variables/pickers/shared/VariableOptions.tsx | 12 +++++++++--- public/locales/de-DE/grafana.json | 9 +++++++++ public/locales/en-US/grafana.json | 9 +++++++++ public/locales/es-ES/grafana.json | 9 +++++++++ public/locales/fr-FR/grafana.json | 9 +++++++++ public/locales/pseudo-LOCALE/grafana.json | 9 +++++++++ public/locales/zh-Hans/grafana.json | 9 +++++++++ 9 files changed, 74 insertions(+), 5 deletions(-) diff --git a/public/app/features/variables/pickers/shared/VariableInput.tsx b/public/app/features/variables/pickers/shared/VariableInput.tsx index 967015709db..8191121d92f 100644 --- a/public/app/features/variables/pickers/shared/VariableInput.tsx +++ b/public/app/features/variables/pickers/shared/VariableInput.tsx @@ -1,5 +1,7 @@ import React, { PureComponent } from 'react'; +import { t } from 'app/core/internationalization'; + import { NavigationKey } from '../types'; export interface Props extends Omit, 'onChange' | 'value'> { @@ -37,7 +39,7 @@ export class VariableInput extends PureComponent { value={value ?? ''} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder="Enter variable value" + placeholder={t('variable.picker.input', 'Enter variable value')} /> ); } diff --git a/public/app/features/variables/pickers/shared/VariableLink.tsx b/public/app/features/variables/pickers/shared/VariableLink.tsx index e35546e4212..d20b2f0b302 100644 --- a/public/app/features/variables/pickers/shared/VariableLink.tsx +++ b/public/app/features/variables/pickers/shared/VariableLink.tsx @@ -4,6 +4,9 @@ import React, { FC, MouseEvent, useCallback } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { ALL_VARIABLE_TEXT } from '../../constants'; interface Props { onClick: () => void; @@ -65,7 +68,11 @@ interface VariableLinkTextProps { const VariableLinkText: FC = ({ text }) => { const styles = useStyles2(getStyles); - return {text}; + return ( + + {text === ALL_VARIABLE_TEXT ? t('variable.picker.link-all', 'All') : text} + + ); }; const LoadingIndicator: FC> = ({ onCancel }) => { diff --git a/public/app/features/variables/pickers/shared/VariableOptions.tsx b/public/app/features/variables/pickers/shared/VariableOptions.tsx index 078abb753cf..ad538e2a408 100644 --- a/public/app/features/variables/pickers/shared/VariableOptions.tsx +++ b/public/app/features/variables/pickers/shared/VariableOptions.tsx @@ -4,7 +4,9 @@ import React, { PureComponent } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { Tooltip, Themeable2, withTheme2, clearButtonStyles } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; +import { ALL_VARIABLE_VALUE } from '../../constants'; import { VariableOption } from '../../types'; export interface Props extends React.HTMLProps, Themeable2 { @@ -62,6 +64,8 @@ class VariableOptions extends PureComponent { const selectClass = option.selected ? 'variable-option pointer selected' : 'variable-option pointer'; const highlightClass = index === highlightIndex ? `${selectClass} highlighted` : selectClass; + const isAllOption = option.value === ALL_VARIABLE_VALUE; + return (
  • @@ -87,8 +91,10 @@ class VariableOptions extends PureComponent { return null; } + const tooltipContent = () => Clear selections; + return ( - + ); diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index cada8dd06d3..f8d014b4763 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -492,5 +492,14 @@ }, "user-sessions": { "loading": "Sitzungen werden geladen …" + }, + "variable": { + "picker": { + "input": "", + "link-all": "", + "option-all": "", + "option-selected-values": "", + "option-tooltip": "" + } } } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index afd9de68fa6..a2948102dec 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -492,5 +492,14 @@ }, "user-sessions": { "loading": "Loading sessions..." + }, + "variable": { + "picker": { + "input": "Enter variable value", + "link-all": "All", + "option-all": "All", + "option-selected-values": "Selected", + "option-tooltip": "Clear selections" + } } } diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index d757a814bc4..279a0268ddb 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -492,5 +492,14 @@ }, "user-sessions": { "loading": "Cargando sesiones..." + }, + "variable": { + "picker": { + "input": "", + "link-all": "", + "option-all": "", + "option-selected-values": "", + "option-tooltip": "" + } } } diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 797aaeb31c2..293a6870412 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -492,5 +492,14 @@ }, "user-sessions": { "loading": "Chargement des sessions..." + }, + "variable": { + "picker": { + "input": "", + "link-all": "", + "option-all": "", + "option-selected-values": "", + "option-tooltip": "" + } } } diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index de4478874a4..11149157a5c 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -492,5 +492,14 @@ }, "user-sessions": { "loading": "Ŀőäđįʼnģ şęşşįőʼnş..." + }, + "variable": { + "picker": { + "input": "Ēʼnŧęř väřįäþľę väľūę", + "link-all": "Åľľ", + "option-all": "Åľľ", + "option-selected-values": "Ŝęľęčŧęđ", + "option-tooltip": "Cľęäř şęľęčŧįőʼnş" + } } } diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index cc4d4fd3512..8b6769c214d 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -492,5 +492,14 @@ }, "user-sessions": { "loading": "正在加载会话..." + }, + "variable": { + "picker": { + "input": "", + "link-all": "", + "option-all": "", + "option-selected-values": "", + "option-tooltip": "" + } } } From d5318f02c60da9a63791c3da423d755abbd3a77e Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Tue, 15 Nov 2022 15:01:22 -0500 Subject: [PATCH 248/926] Changelog: Updated changelog for 9.3.0-beta1 (#58785) --- CHANGELOG.md | 170 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 236a4698261..d4e9d9e2506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,173 @@ + + +# 9.3.0-beta1 (2022-11-15) + +### Features and enhancements + +- **Alerting:** Add Alertmanager choice warning. [#55311](https://github.com/grafana/grafana/pull/55311), [@konrad147](https://github.com/konrad147) +- **Alerting:** Add support for linking external images securely - Azure Blob (#1). [#56598](https://github.com/grafana/grafana/pull/56598), [@petr-stupka](https://github.com/petr-stupka) +- **Alerting:** Add threshold expression. [#55102](https://github.com/grafana/grafana/pull/55102), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Add traceability headers for alert queries. [#57127](https://github.com/grafana/grafana/pull/57127), [@alexweav](https://github.com/alexweav) +- **Alerting:** Allow none provenance alert rule creation from provisioning API. [#58410](https://github.com/grafana/grafana/pull/58410), [@alexmobo](https://github.com/alexmobo) +- **Alerting:** Cache result of dashboard ID lookups. [#56587](https://github.com/grafana/grafana/pull/56587), [@alexweav](https://github.com/alexweav) +- **Alerting:** Expressions pipeline redesign. [#54601](https://github.com/grafana/grafana/pull/54601), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Fall back to "range" query type for unified alerting when "both" is specified. [#57288](https://github.com/grafana/grafana/pull/57288), [@gillesdemey](https://github.com/gillesdemey) +- **Alerting:** Implement the Webex notifier. [#58480](https://github.com/grafana/grafana/pull/58480), [@gotjosh](https://github.com/gotjosh) +- **Alerting:** Improve group modal with validation on evaluation interval. [#57830](https://github.com/grafana/grafana/pull/57830), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron) +- **Alerting:** Persist annotations from multidimensional rules in batches. [#56575](https://github.com/grafana/grafana/pull/56575), [@alexweav](https://github.com/alexweav) +- **Alerting:** Query time logging. [#57585](https://github.com/grafana/grafana/pull/57585), [@konrad147](https://github.com/konrad147) +- **Alerting:** Remove the alert manager selection from the data source configuration. [#57369](https://github.com/grafana/grafana/pull/57369), [@VikaCep](https://github.com/VikaCep) +- **Alerting:** Remove the alert manager selection from the data source configuration. [#56460](https://github.com/grafana/grafana/pull/56460), [@gitstart](https://github.com/gitstart) +- **Alerting:** Support values in notification templates. [#56457](https://github.com/grafana/grafana/pull/56457), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **Alerting:** Templated URLs for webhook type contact points. [#57296](https://github.com/grafana/grafana/pull/57296), [@santihernandezc](https://github.com/santihernandezc) +- **Annotations:** Disable "Add annotation" button when annotations are disabled. [#57481](https://github.com/grafana/grafana/pull/57481), [@ryantxu](https://github.com/ryantxu) +- **Auth:** Add validation and ingestion of conflict file. [#53014](https://github.com/grafana/grafana/pull/53014), [@eleijonmarck](https://github.com/eleijonmarck) +- **Auth:** Make built-in login configurable. [#46978](https://github.com/grafana/grafana/pull/46978), [@TsotosA](https://github.com/TsotosA) +- **Auth:** Refresh OAuth access_token automatically using the refresh_token. [#56076](https://github.com/grafana/grafana/pull/56076), [@mgyongyosi](https://github.com/mgyongyosi) +- **Auth:** Validate Azure ID token version on login is not v1. [#58088](https://github.com/grafana/grafana/pull/58088), [@Jguer](https://github.com/Jguer) +- **BackendSrv:** Make it possible to pass `options` to `.get|post|patch...` methods. [#51316](https://github.com/grafana/grafana/pull/51316), [@leventebalogh](https://github.com/leventebalogh) +- **Canvas:** Add tabs to inline editor. [#57778](https://github.com/grafana/grafana/pull/57778), [@adela-almasan](https://github.com/adela-almasan) +- **Canvas:** Extend root context menu. [#58097](https://github.com/grafana/grafana/pull/58097), [@adela-almasan](https://github.com/adela-almasan) +- **Chore:** Switch Grafana to using faro libraries. [#58186](https://github.com/grafana/grafana/pull/58186), [@tolzhabayev](https://github.com/tolzhabayev) +- **Chore:** Use strings.ReplaceAll and preallocate containers. [#58483](https://github.com/grafana/grafana/pull/58483), [@sashamelentyev](https://github.com/sashamelentyev) +- **CloudWatch:** Cache resource request responses in the browser. [#57082](https://github.com/grafana/grafana/pull/57082), [@sunker](https://github.com/sunker) +- **Config:** Change jwt config value to be "expect_claims". [#58284](https://github.com/grafana/grafana/pull/58284), [@conorevans](https://github.com/conorevans) +- **Configuration:** Update ssl_mode documentation in sample.ini to match default.ini. [#55138](https://github.com/grafana/grafana/pull/55138), [@alecxvs](https://github.com/alecxvs) +- **Correlations:** Add query editor and target field to settings page. [#55567](https://github.com/grafana/grafana/pull/55567), [@Elfo404](https://github.com/Elfo404) +- **Dashboard:** Record the number of cached queries for usage insights. [#56050](https://github.com/grafana/grafana/pull/56050), [@juanicabanas](https://github.com/juanicabanas) +- **Dashboard:** Record the number of cached queries for usage insights. (Enterprise) +- **Datasources:** Support mixed datasources in a single query. [#56832](https://github.com/grafana/grafana/pull/56832), [@mmandrus](https://github.com/mmandrus) +- **Docs:** Add documentation for Custom Branding on Public Dashboards. [#58090](https://github.com/grafana/grafana/pull/58090), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **Docs:** Add missing documentation for enterprise features. [#56753](https://github.com/grafana/grafana/pull/56753), [@mmandrus](https://github.com/mmandrus) +- **Docs:** Clarify that audit logs are generated only for API requests. [#57521](https://github.com/grafana/grafana/pull/57521), [@spinillos](https://github.com/spinillos) +- **Echo:** Add config option to prevent duplicate page views for GA4. [#57619](https://github.com/grafana/grafana/pull/57619), [@tolzhabayev](https://github.com/tolzhabayev) +- **Elasticsearch:** Add trace to logs functionality. [#58063](https://github.com/grafana/grafana/pull/58063), [@ivanahuckova](https://github.com/ivanahuckova) +- **Elasticsearch:** Reuse http client in the backend. [#55172](https://github.com/grafana/grafana/pull/55172), [@gabor](https://github.com/gabor) +- **Explore:** Add tracesToMetrics span time shift options (#54710). [#55335](https://github.com/grafana/grafana/pull/55335), [@hanjm](https://github.com/hanjm) +- **Explore:** Logs volume histogram: always start Y axis from zero. [#56200](https://github.com/grafana/grafana/pull/56200), [@gabor](https://github.com/gabor) +- **Explore:** Remove explore2Dashboard feature toggle. [#58329](https://github.com/grafana/grafana/pull/58329), [@Elfo404](https://github.com/Elfo404) +- **Explore:** Support fields interpolation in logs panel. [#58426](https://github.com/grafana/grafana/pull/58426), [@ifrost](https://github.com/ifrost) +- **Frontend Routing:** Always render standalone plugin pages using the ``. [#57771](https://github.com/grafana/grafana/pull/57771), [@leventebalogh](https://github.com/leventebalogh) +- **GRPC Server:** Add gRPC server service. [#47849](https://github.com/grafana/grafana/pull/47849), [@FZambia](https://github.com/FZambia) +- **Geomap:** Add photo layer. [#57307](https://github.com/grafana/grafana/pull/57307), [@drew08t](https://github.com/drew08t) +- **Geomap:** Upgrade to openlayers 7.x. [#57317](https://github.com/grafana/grafana/pull/57317), [@ryantxu](https://github.com/ryantxu) +- **GrafanaData:** Deprecate logs functions. [#56077](https://github.com/grafana/grafana/pull/56077), [@gabor](https://github.com/gabor) +- **GrafanaData:** Deprecate the LogsParser type. [#56242](https://github.com/grafana/grafana/pull/56242), [@gabor](https://github.com/gabor) +- **Kindsys:** Introduce Kind framework. [#56492](https://github.com/grafana/grafana/pull/56492), [@sdboyer](https://github.com/sdboyer) +- **LDAP:** Add `skip_org_role_sync` configuration option. [#56792](https://github.com/grafana/grafana/pull/56792), [@grafanabot](https://github.com/grafanabot) +- **LDAP:** Add `skip_org_role_sync` configuration option. [#56679](https://github.com/grafana/grafana/pull/56679), [@gamab](https://github.com/gamab) +- **LDAPSync:** Improve performance of sync and make it case insensitive. (Enterprise) +- **LibraryPanels:** Load library panels in the frontend rather than the backend. [#50560](https://github.com/grafana/grafana/pull/50560), [@ryantxu](https://github.com/ryantxu) +- **LogContext:** Add header and close button to modal. [#56283](https://github.com/grafana/grafana/pull/56283), [@svennergr](https://github.com/svennergr) +- **LogContext:** Improve text describing the loglines. [#55475](https://github.com/grafana/grafana/pull/55475), [@svennergr](https://github.com/svennergr) +- **Logs:** Allow collapsing the logs volume histogram. [#52808](https://github.com/grafana/grafana/pull/52808), [@gabor](https://github.com/gabor) +- **Logs:** Center `show context` modal on click. [#55989](https://github.com/grafana/grafana/pull/55989), [@svennergr](https://github.com/svennergr) +- **Logs:** Center `show context` modal on click. [#55405](https://github.com/grafana/grafana/pull/55405), [@svennergr](https://github.com/svennergr) +- **Logs:** Show LogRowMenu also for long logs and wrap-lines turned off. [#56030](https://github.com/grafana/grafana/pull/56030), [@svennergr](https://github.com/svennergr) +- **LogsContext:** Added button to load 10 more log lines. [#55923](https://github.com/grafana/grafana/pull/55923), [@svennergr](https://github.com/svennergr) +- **Loki:** Add case insensitive line contains operation. [#58177](https://github.com/grafana/grafana/pull/58177), [@gwdawson](https://github.com/gwdawson) +- **Loki:** Monaco Query Editor enabled by default. [#58080](https://github.com/grafana/grafana/pull/58080), [@matyax](https://github.com/matyax) +- **Loki:** Redesign and improve query patterns. [#55097](https://github.com/grafana/grafana/pull/55097), [@ivanahuckova](https://github.com/ivanahuckova) +- **Loki:** Rename log browser to label browser. [#58416](https://github.com/grafana/grafana/pull/58416), [@gwdawson](https://github.com/gwdawson) +- **Loki:** Show invalid fields in label filter. [#55751](https://github.com/grafana/grafana/pull/55751), [@ivanahuckova](https://github.com/ivanahuckova) +- **MSSQL:** Add connection timeout setting in configuration page. [#58631](https://github.com/grafana/grafana/pull/58631), [@mdvictor](https://github.com/mdvictor) +- **Navigation:** Add `pluginId` to standalone plugin page NavLinks. [#57769](https://github.com/grafana/grafana/pull/57769), [@leventebalogh](https://github.com/leventebalogh) +- **Navigation:** Expose new props to extend `Page`/`PluginPage`. [#58465](https://github.com/grafana/grafana/pull/58465), [@ashharrison90](https://github.com/ashharrison90) +- **Navtree:** Make it possible to configure standalone plugin pages. [#56393](https://github.com/grafana/grafana/pull/56393), [@leventebalogh](https://github.com/leventebalogh) +- **Node Graph:** Always show context menu. [#56876](https://github.com/grafana/grafana/pull/56876), [@joey-grafana](https://github.com/joey-grafana) +- **Number formatting:** Strip trailing zeros after decimal point when decimals=auto. [#57373](https://github.com/grafana/grafana/pull/57373), [@leeoniya](https://github.com/leeoniya) +- **OAuth:** Feature toggle for access token expiration check and docs. [#58179](https://github.com/grafana/grafana/pull/58179), [@mgyongyosi](https://github.com/mgyongyosi) +- **Opentsdb:** Allow template variables for filter keys. [#57226](https://github.com/grafana/grafana/pull/57226), [@bohandley](https://github.com/bohandley) +- **PanelEdit:** Allow test id to be passed to panel editors. [#55417](https://github.com/grafana/grafana/pull/55417), [@mckn](https://github.com/mckn) +- **Plugins:** Add hook to make it easier to track interactions in plugins. [#56126](https://github.com/grafana/grafana/pull/56126), [@mckn](https://github.com/mckn) +- **Plugins:** Introduce new Flame graph panel. [#56376](https://github.com/grafana/grafana/pull/56376), [@joey-grafana](https://github.com/joey-grafana) +- **Plugins:** Make "README" the default markdown request param. [#58264](https://github.com/grafana/grafana/pull/58264), [@wbrowne](https://github.com/wbrowne) +- **PostgreSQL:** Migrate to React. [#52831](https://github.com/grafana/grafana/pull/52831), [@zoltanbedi](https://github.com/zoltanbedi) +- **Preferences:** Create indices. [#48356](https://github.com/grafana/grafana/pull/48356), [@sakjur](https://github.com/sakjur) +- **Profiling:** Add Phlare and Parca datasources. [#57809](https://github.com/grafana/grafana/pull/57809), [@aocenas](https://github.com/aocenas) +- **Prometheus:** Handle errors and warnings in buffered client. [#58504](https://github.com/grafana/grafana/pull/58504), [@itsmylife](https://github.com/itsmylife) +- **Prometheus:** Make Prometheus streaming parser as default client. [#58365](https://github.com/grafana/grafana/pull/58365), [@itsmylife](https://github.com/itsmylife) +- **Public Dashboards:** Add audit table. [#54508](https://github.com/grafana/grafana/pull/54508), [@jalevin](https://github.com/jalevin) +- **PublicDashboards:** Add PubDash support to Angular panel plugins. [#57293](https://github.com/grafana/grafana/pull/57293), [@mmandrus](https://github.com/mmandrus) +- **PublicDashboards:** Add annotations support. [#56413](https://github.com/grafana/grafana/pull/56413), [@owensmallwood](https://github.com/owensmallwood) +- **PublicDashboards:** Add custom branding for Public Dashboard. (Enterprise) +- **PublicDashboards:** Add delete public dashboard button in public dashboard modal. [#58095](https://github.com/grafana/grafana/pull/58095), [@juanicabanas](https://github.com/juanicabanas) +- **PublicDashboards:** Cached queries column added in public dashboard insight query. (Enterprise) +- **PublicDashboards:** Can toggle annotations in modal. [#57312](https://github.com/grafana/grafana/pull/57312), [@owensmallwood](https://github.com/owensmallwood) +- **PublicDashboards:** Delete public dashboard in public dashboard table. [#57766](https://github.com/grafana/grafana/pull/57766), [@juanicabanas](https://github.com/juanicabanas) +- **PublicDashboards:** Delete public dashboard when dashboard is deleted. [#57291](https://github.com/grafana/grafana/pull/57291), [@juanicabanas](https://github.com/juanicabanas) +- **PublicDashboards:** Extract config of Public Dashboard. [#57788](https://github.com/grafana/grafana/pull/57788), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **PublicDashboards:** Hide top navigation bar. [#56873](https://github.com/grafana/grafana/pull/56873), [@evictorero](https://github.com/evictorero) +- **PublicDashboards:** Make mixed datasource calls concurrently. [#56421](https://github.com/grafana/grafana/pull/56421), [@juanicabanas](https://github.com/juanicabanas) +- **PublicDashboards:** Orphaned public dashboard item list modified. [#58014](https://github.com/grafana/grafana/pull/58014), [@juanicabanas](https://github.com/juanicabanas) +- **PublicDashboards:** Rename PubdashFooter frontend component. [#58137](https://github.com/grafana/grafana/pull/58137), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **PublicDashboards:** Update docs with supported datasources. [#57629](https://github.com/grafana/grafana/pull/57629), [@owensmallwood](https://github.com/owensmallwood) +- **PublicDashboards:** Validate access token. [#57298](https://github.com/grafana/grafana/pull/57298), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **PublicDashboards:** Validate access token not to be duplicated and add retries. [#56755](https://github.com/grafana/grafana/pull/56755), [@juanicabanas](https://github.com/juanicabanas) +- **RBAC:** Improve performance of dashboard filter query. [#56813](https://github.com/grafana/grafana/pull/56813), [@kalleep](https://github.com/kalleep) +- **Rendering:** Add configuration options for `renderKey` lifetime. [#57339](https://github.com/grafana/grafana/pull/57339), [@Willena](https://github.com/Willena) +- **Reports:** Dynamic scale factor per report. (Enterprise) +- **SAML:** Set cookie option SameSite=none and Secure=true. (Enterprise) +- **SQLStore:** Optionally retry queries if sqlite returns database is locked. [#56096](https://github.com/grafana/grafana/pull/56096), [@papagian](https://github.com/papagian) +- **Server:** Make unix socket permission configurable. [#52944](https://github.com/grafana/grafana/pull/52944), [@unknowndevQwQ](https://github.com/unknowndevQwQ) +- **Tempo:** Add start time and end time parameters while querying traces. [#48068](https://github.com/grafana/grafana/pull/48068), [@bikashmishra100](https://github.com/bikashmishra100) +- **TimeSeries:** Render null-bounded points at data edges. [#57798](https://github.com/grafana/grafana/pull/57798), [@leeoniya](https://github.com/leeoniya) +- **Tracing:** Allow trace to logs for OpenSearch. [#58161](https://github.com/grafana/grafana/pull/58161), [@gabor](https://github.com/gabor) +- **Transformers:** PartitionByValues. [#56767](https://github.com/grafana/grafana/pull/56767), [@leeoniya](https://github.com/leeoniya) +- **UsageStats:** Add traces when sending usage stats. [#55474](https://github.com/grafana/grafana/pull/55474), [@sakjur](https://github.com/sakjur) + +### Bug fixes + +- **Alerting:** Fix mathexp.NoData in ConditionsCmd. [#56812](https://github.com/grafana/grafana/pull/56812), [@grobinson-grafana](https://github.com/grobinson-grafana) +- **BarChart:** Fix coloring from thresholds and value mappings. [#58285](https://github.com/grafana/grafana/pull/58285), [@leeoniya](https://github.com/leeoniya) +- **BarChart:** Fix stacked hover. [#57711](https://github.com/grafana/grafana/pull/57711), [@leeoniya](https://github.com/leeoniya) +- **Explore:** Fix shared crosshair for logs, logsvolume and graph panels. [#57892](https://github.com/grafana/grafana/pull/57892), [@Elfo404](https://github.com/Elfo404) +- **Flame Graph:** Exact search. [#56769](https://github.com/grafana/grafana/pull/56769), [@joey-grafana](https://github.com/joey-grafana) +- **Flame Graph:** Fix for dashboard scrolling. [#56555](https://github.com/grafana/grafana/pull/56555), [@joey-grafana](https://github.com/joey-grafana) +- **LogContext:** Fix scroll behavior in context modal. [#56070](https://github.com/grafana/grafana/pull/56070), [@svennergr](https://github.com/svennergr) +- **Loki:** Fix showing of history of querying in query editor. [#57344](https://github.com/grafana/grafana/pull/57344), [@ivanahuckova](https://github.com/ivanahuckova) +- **OAuth:** Fix misleading warn log related to oauth and increase logged content. [#57336](https://github.com/grafana/grafana/pull/57336), [@Jguer](https://github.com/Jguer) +- **Plugins:** Plugin details page visual alignment issues. [#57729](https://github.com/grafana/grafana/issues/57729) +- **PublicDashboards:** Fix GET public dashboard that doesn't match. [#57571](https://github.com/grafana/grafana/pull/57571), [@juanicabanas](https://github.com/juanicabanas) +- **PublicDashboards:** Fix annotations error for public dashboards. [#57455](https://github.com/grafana/grafana/pull/57455), [@leandro-deveikis](https://github.com/leandro-deveikis) +- **PublicDashboards:** Fix granularity discrepancy between public and original dashboard. [#57129](https://github.com/grafana/grafana/pull/57129), [@guicaulada](https://github.com/guicaulada) +- **PublicDashboards:** Fix granularity issue caused by query caching. (Enterprise) +- **PublicDashboards:** Fix hidden queries execution. (Enterprise) +- **RBAC:** Add primary key to seed_assignment table. [#56540](https://github.com/grafana/grafana/pull/56540), [@kalleep](https://github.com/kalleep) +- **Tempo:** Fix search removing service name from query. [#58630](https://github.com/grafana/grafana/pull/58630), [@joey-grafana](https://github.com/joey-grafana) +- **TimeRangeInput:** Fix clear button type. [#56545](https://github.com/grafana/grafana/pull/56545), [@Clarity-89](https://github.com/Clarity-89) + +### Breaking changes + +Removes the unused close-milestone command from `@grafana/toolkit`. Issue [#57062](https://github.com/grafana/grafana/issues/57062) + +@grafana/toolkit `cherrypick` command was removed. Issue [#56114](https://github.com/grafana/grafana/issues/56114) + +`EmotionPerfTest` is no longer exported from the `@grafana/ui` bundle. Issue [#56100](https://github.com/grafana/grafana/issues/56100) + +Removing the unused `changelog` command in `@grafana/toolkit`. Issue [#56073](https://github.com/grafana/grafana/issues/56073) + +### Deprecations + +The interface type `LogsParser` in `grafana-data` is deprecated. Issue [#56242](https://github.com/grafana/grafana/issues/56242) + +The following functions and classes related to logs are deprecated in the `grafana-ui` package: `getLogLevel`, `getLogLevelFromKey`, `addLogLevelToSeries`, `LogsParsers`, `calculateFieldStats`, `calculateLogsLabelStats`, `calculateStats`, `getParser`, `sortInAscendingOrder`, `sortInDescendingOrder`, `sortLogsResult`, `sortLogRows`, `checkLogsError`, `escapeUnescapedString`. Issue [#56077](https://github.com/grafana/grafana/issues/56077) + +### Plugin development fixes & changes + +- **Toolkit:** Deprecate `plugin:update-circleci` command. [#57743](https://github.com/grafana/grafana/pull/57743), [@academo](https://github.com/academo) +- **Toolkit:** Deprecate `plugin:github-publish` command. [#57726](https://github.com/grafana/grafana/pull/57726), [@academo](https://github.com/academo) +- **Toolkit:** Deprecate `plugin:bundle-managed` command and move its functionality to a bash script. [#57719](https://github.com/grafana/grafana/pull/57719), [@academo](https://github.com/academo) +- **Toolkit:** Deprecate and replace toolkit:build with plain yarn scripts. [#57620](https://github.com/grafana/grafana/pull/57620), [@academo](https://github.com/academo) +- **Toolkit:** Deprecate node-version-check command. [#57591](https://github.com/grafana/grafana/pull/57591), [@academo](https://github.com/academo) +- **Toolkit:** Deprecate searchTestData command. [#57589](https://github.com/grafana/grafana/pull/57589), [@academo](https://github.com/academo) +- **Toolkit:** Remove unused close-milestone command. [#57062](https://github.com/grafana/grafana/pull/57062), [@academo](https://github.com/academo) +- **Toolkit:** Remove unused legacy cherrypick command. [#56114](https://github.com/grafana/grafana/pull/56114), [@academo](https://github.com/academo) +- **Grafana UI:** Clean up bundle. [#56100](https://github.com/grafana/grafana/pull/56100), [@jackw](https://github.com/jackw) +- **Toolkit:** Deprecate `component:create` command. [#56086](https://github.com/grafana/grafana/pull/56086), [@academo](https://github.com/academo) +- **Toolkit:** Remove changelog command. [#56073](https://github.com/grafana/grafana/pull/56073), [@gitstart](https://github.com/gitstart) + + # 9.2.4 (2022-11-07) From 3d016d67a299dd4870a30554b12348df778de670 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Tue, 15 Nov 2022 23:32:34 +0200 Subject: [PATCH 249/926] latest.json: Update `latest.json` to 9.3.0-beta1 (#58788) Update latest.json --- latest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/latest.json b/latest.json index a0b6b076021..ce531a0d71a 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { "stable": "9.2.4", - "testing": "9.2.4" + "testing": "9.3.0-beta1" } From 5bd15026ff48b948ca2eebc0b7f672cfc450b032 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Wed, 16 Nov 2022 10:06:42 +0100 Subject: [PATCH 250/926] Docs: How to add plugin interaction tracking (#58652) * docs for plugin interaction tracking. * Update docs/sources/developers/plugins/add-anonymous-usage-reporting.md Co-authored-by: Marcus Efraimsson * Adding query type * Fixed spelling issue Co-authored-by: Marcus Efraimsson --- .../plugins/add-anonymous-usage-reporting.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/sources/developers/plugins/add-anonymous-usage-reporting.md diff --git a/docs/sources/developers/plugins/add-anonymous-usage-reporting.md b/docs/sources/developers/plugins/add-anonymous-usage-reporting.md new file mode 100644 index 00000000000..09b1b889364 --- /dev/null +++ b/docs/sources/developers/plugins/add-anonymous-usage-reporting.md @@ -0,0 +1,153 @@ +--- +aliases: + - /docs/grafana/latest/developers/plugins/add-anonymous-usage-reporting/ +title: Add anonymous usage reporting +--- + +# Add anonymous usage reporting to you plugin + +The Grafana server administrator has the possibility to configure [anonymous usage tracking]({{< relref "../../setup-grafana/configure-grafana/#reporting_enabled" >}}). + +By adding usage tracking to your plugin you will send events of how your plugin is being used to the configured tracking system. + +Lets say we have a QueryEditor that looks something like the example below. It has an editor field where you can write your query and a query type selector so you can select what kind of query result you are expecting that query to return. + +```ts +import React, { ReactElement } from 'react'; +import { InlineFieldRow, InlineField, Select, CodeEditor } from '@grafana/ui'; +import type { EditorProps } from './types'; + +export function QueryEditor(props: EditorProps): ReactElement { + const { datasource, query, onChange, onRunQuery } = props; + const queryType = { value: query.value ?? 'timeserie' }; + const queryTypes = [ + { + label: 'Timeserie', + value: 'timeserie', + }, + { + label: 'Table', + value: 'table', + }, + ]; + + const onChangeQueryType = (type: string) => { + onChange({ + ...query, + queryType: type, + }); + runQuery(); + }; + + const onChangeRawQuery = (rawQuery: string) => { + onChange({ + ...query, + rawQuery: type, + }); + runQuery(); + }; + + return ( + <> +
    + +
    + + + + + + + ); +} +``` + +Another benefit of using the `usePluginInteractionReporter` is that the report function that is handed back to you will automatically attach contextual data about the plugin you are tracking to every event. In our example the following information will be sent to the analytics service configured by the Grafana server administrator. + +```ts +{ + type: 'interaction', + payload: { + interactionName: 'grafana_plugin_executed_query', + grafana_version: '9.2.1', + plugin_type: 'datasource', + plugin_version: '1.0.0', + plugin_id: 'grafana-example-datasource', + plugin_name: 'Example', + datasource_uid: 'qeSI8VV7z', // will only be added for datasources + query_type: 'timeserie' + } +} +``` From 174a039ee1df6ba885283dfd8b57d4d99e98457d Mon Sep 17 00:00:00 2001 From: Timur Olzhabayev Date: Wed, 16 Nov 2022 10:26:38 +0100 Subject: [PATCH 251/926] Fix: Bump-version action regex pattern to work with beta1 (#58805) Fixing bump version regex --- .github/workflows/bump-version.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 2f253c6927d..b07cf46eeba 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -17,7 +17,7 @@ jobs: id: regex-match with: text: ${{ github.event.inputs.version }} - regex: '^(\d+.\d+).\d+(?:-beta.\d+)?$' + regex: '^(\d+.\d+).\d+(?:-beta\d+)?$' - uses: actions-ecosystem/action-regex-match@v2.0.2 if: ${{ inputs.version_call != '' }} id: regex-match-version-call @@ -29,7 +29,7 @@ jobs: run: | echo "The input version format is not correct, please respect:\ major.minor.patch or major.minor.patch-beta.number format. \ - example: 7.4.3 or 7.4.3-beta.1" + example: 7.4.3 or 7.4.3-beta1" exit 1 - name: Validate input version call if: ${{ inputs.version_call != '' && steps.regex-match-version-call.outputs.match == '' }} From 2a9381e9987c02a85c7daf738fc657949964318d Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Wed, 16 Nov 2022 11:16:27 +0100 Subject: [PATCH 252/926] Explore: Refactor ExploreGraph (#58660) * WIP * revert collapse changes * use HorizontalGroup instead of custom styles * fix tests * use import aliases --- public/app/core/utils/explore.ts | 17 +---- public/app/features/explore/Explore.test.tsx | 2 - public/app/features/explore/Explore.tsx | 51 +++++--------- .../explore/{ => Graph}/ExploreGraph.tsx | 21 +++--- .../explore/{ => Graph}/ExploreGraphLabel.tsx | 15 ++-- .../features/explore/Graph/GraphContainer.tsx | 70 +++++++++++++++++++ .../{ => Graph}/exploreGraphStyleUtils.ts | 3 +- public/app/features/explore/Graph/utils.ts | 26 +++++++ .../features/explore/LogsVolumePanel.test.tsx | 2 +- .../app/features/explore/LogsVolumePanel.tsx | 4 +- .../app/features/explore/state/explorePane.ts | 22 +----- public/app/features/explore/state/utils.ts | 10 +-- public/app/types/explore.ts | 2 - 13 files changed, 136 insertions(+), 109 deletions(-) rename public/app/features/explore/{ => Graph}/ExploreGraph.tsx (87%) rename public/app/features/explore/{ => Graph}/ExploreGraphLabel.tsx (71%) create mode 100644 public/app/features/explore/Graph/GraphContainer.tsx rename public/app/features/explore/{ => Graph}/exploreGraphStyleUtils.ts (97%) create mode 100644 public/app/features/explore/Graph/utils.ts diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 5607898732f..dce6c2e66ad 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -30,7 +30,7 @@ import { RefreshPicker } from '@grafana/ui'; import store from 'app/core/store'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { PanelModel } from 'app/features/dashboard/state'; -import { EXPLORE_GRAPH_STYLES, ExploreGraphStyle, ExploreId, QueryOptions, QueryTransaction } from 'app/types/explore'; +import { ExploreId, QueryOptions, QueryTransaction } from 'app/types/explore'; import { config } from '../config'; @@ -205,21 +205,6 @@ export const safeStringifyValue = (value: any, space?: number) => { return ''; }; -const DEFAULT_GRAPH_STYLE: ExploreGraphStyle = 'lines'; -// we use this function to take any kind of data we loaded -// from an external source (URL, localStorage, whatever), -// and extract the graph-style from it, or return the default -// graph-style if we are not able to do that. -// it is important that this function is able to take any form of data, -// (be it objects, or arrays, or booleans or whatever), -// and produce a best-effort graphStyle. -// note that typescript makes sure we make no mistake in this function. -// we do not rely on ` as ` or ` any `. -export const toGraphStyle = (data: unknown): ExploreGraphStyle => { - const found = EXPLORE_GRAPH_STYLES.find((v) => v === data); - return found ?? DEFAULT_GRAPH_STYLE; -}; - export function parseUrlState(initial: string | undefined): ExploreUrlState { const parsed = safeParseJson(initial); const errorResult: any = { diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 151b6930c7a..619ef022261 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -84,8 +84,6 @@ const dummyProps: Props = { showFlameGraph: true, splitOpen: (() => {}) as any, splitted: false, - changeGraphStyle: () => {}, - graphStyle: 'lines', eventBus: new EventBusSrv(), }; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index a5f7b1ce657..e4b3f488fac 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -18,7 +18,7 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; -import { Collapse, CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2, PanelContainer } from '@grafana/ui'; +import { CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2, PanelContainer } from '@grafana/ui'; import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types'; import appEvents from 'app/core/app_events'; import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider'; @@ -26,15 +26,14 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils'; import { StoreState } from 'app/types'; import { AbsoluteTimeEvent } from 'app/types/events'; -import { ExploreGraphStyle, ExploreId, ExploreItemState } from 'app/types/explore'; +import { ExploreId, ExploreItemState } from 'app/types/explore'; import { getTimeZone } from '../profile/state/selectors'; -import { ExploreGraph } from './ExploreGraph'; -import { ExploreGraphLabel } from './ExploreGraphLabel'; import ExploreQueryInspector from './ExploreQueryInspector'; import { ExploreToolbar } from './ExploreToolbar'; import { FlameGraphExploreContainer } from './FlameGraphExploreContainer'; +import { GraphContainer } from './Graph/GraphContainer'; import LogsContainer from './LogsContainer'; import { NoData } from './NoData'; import { NoDataSourceCallToAction } from './NoDataSourceCallToAction'; @@ -45,7 +44,7 @@ import RichHistoryContainer from './RichHistory/RichHistoryContainer'; import { SecondaryActions } from './SecondaryActions'; import TableContainer from './TableContainer'; import { TraceViewContainer } from './TraceView/TraceViewContainer'; -import { changeSize, changeGraphStyle } from './state/explorePane'; +import { changeSize } from './state/explorePane'; import { splitOpen } from './state/main'; import { addQueryRow, modifyQueries, scanStart, scanStopAction, setQueries } from './state/query'; import { isSplit } from './state/selectors'; @@ -222,11 +221,6 @@ export class Explore extends React.PureComponent { updateTimeRange({ exploreId, absoluteRange }); }; - onChangeGraphStyle = (graphStyle: ExploreGraphStyle) => { - const { exploreId, changeGraphStyle } = this.props; - changeGraphStyle(exploreId, graphStyle); - }; - toggleShowRichHistory = () => { this.setState((state) => { return { @@ -277,28 +271,22 @@ export class Explore extends React.PureComponent { } renderGraphPanel(width: number) { - const { graphResult, absoluteRange, timeZone, queryResponse, loading, theme, graphStyle, showFlameGraph } = - this.props; - const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); - const label = ; + const { graphResult, absoluteRange, timeZone, queryResponse, loading, showFlameGraph } = this.props; return ( - - - + ); } @@ -515,7 +503,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { showNodeGraph, showFlameGraph, loading, - graphStyle, } = item; return { @@ -538,13 +525,11 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { showFlameGraph, splitted: isSplit(state), loading, - graphStyle, }; } const mapDispatchToProps = { changeSize, - changeGraphStyle, modifyQueries, scanStart, scanStopAction, diff --git a/public/app/features/explore/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx similarity index 87% rename from public/app/features/explore/ExploreGraph.tsx rename to public/app/features/explore/Graph/ExploreGraph.tsx index fc3613c59e8..f8a0e02f0d4 100644 --- a/public/app/features/explore/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -31,9 +31,9 @@ import { } from '@grafana/ui'; import { defaultGraphConfig, getGraphFieldConfig } from 'app/plugins/panel/timeseries/config'; import { TimeSeriesOptions } from 'app/plugins/panel/timeseries/types'; +import { ExploreGraphStyle } from 'app/types'; -import { ExploreGraphStyle } from '../../types'; -import { seriesVisibilityConfigFactory } from '../dashboard/dashgrid/SeriesVisibilityConfigFactory'; +import { seriesVisibilityConfigFactory } from '../../dashboard/dashgrid/SeriesVisibilityConfigFactory'; import { applyGraphStyle } from './exploreGraphStyleUtils'; @@ -52,7 +52,7 @@ interface Props { splitOpenFn: SplitOpen; onChangeTime: (timeRange: AbsoluteTimeRange) => void; graphStyle: ExploreGraphStyle; - anchorToZero: boolean; + anchorToZero?: boolean; eventBus: EventBus; } @@ -69,13 +69,14 @@ export function ExploreGraph({ splitOpenFn, graphStyle, tooltipDisplayMode = TooltipDisplayMode.Single, - anchorToZero, + anchorToZero = false, eventBus, }: Props) { const theme = useTheme2(); const style = useStyles2(getStyles); const [showAllTimeSeries, setShowAllTimeSeries] = useState(false); - const [structureRev, { inc: incrementStructureRev }] = useCounter(1); + const [structureRev, { inc }] = useCounter(0); + const fieldConfigRegistry = useMemo( () => createFieldConfigRegistry(getGraphFieldConfig(defaultGraphConfig), 'Explore'), [] @@ -118,12 +119,10 @@ export function ExploreGraph({ }); }, [fieldConfigRegistry, data, timeZone, theme, styledFieldConfig]); - // structureRev should be incremented when either the number of series or the config changes. - // like useEffect, but runs before rendering. - // TODO: while this works as it is supposed to, we are forced to do this now because of the way - // ExploreGraph is implemented. We should refactor it to a single component that handles structureRev increments - // when a user changes the viz style and not react to the value change itself. - useMemo(incrementStructureRev, [dataWithConfig.length, styledFieldConfig, incrementStructureRev]); + // We need to increment structureRev when the number of series changes. + // the function passed to useMemo runs during rendering, so when we get a different + // amount of data, structureRev is incremented before we render it + useMemo(inc, [dataWithConfig.length, styledFieldConfig, inc]); useEffect(() => { if (onHiddenSeriesChanged) { diff --git a/public/app/features/explore/ExploreGraphLabel.tsx b/public/app/features/explore/Graph/ExploreGraphLabel.tsx similarity index 71% rename from public/app/features/explore/ExploreGraphLabel.tsx rename to public/app/features/explore/Graph/ExploreGraphLabel.tsx index 6075ea08f92..576a55e10e5 100644 --- a/public/app/features/explore/ExploreGraphLabel.tsx +++ b/public/app/features/explore/Graph/ExploreGraphLabel.tsx @@ -1,10 +1,8 @@ -import { css } from '@emotion/css'; import React from 'react'; import { SelectableValue } from '@grafana/data'; -import { RadioButtonGroup } from '@grafana/ui'; - -import { EXPLORE_GRAPH_STYLES, ExploreGraphStyle } from '../../types'; +import { RadioButtonGroup, HorizontalGroup } from '@grafana/ui'; +import { EXPLORE_GRAPH_STYLES, ExploreGraphStyle } from 'app/types'; const ALL_GRAPH_STYLE_OPTIONS: Array> = EXPLORE_GRAPH_STYLES.map((style) => ({ value: style, @@ -12,11 +10,6 @@ const ALL_GRAPH_STYLE_OPTIONS: Array> = EXPLO label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '), })); -const spacing = css({ - display: 'flex', - justifyContent: 'space-between', -}); - type Props = { graphStyle: ExploreGraphStyle; onChangeGraphStyle: (style: ExploreGraphStyle) => void; @@ -25,9 +18,9 @@ type Props = { export function ExploreGraphLabel(props: Props) { const { graphStyle, onChangeGraphStyle } = props; return ( -
    + Graph -
    + ); } diff --git a/public/app/features/explore/Graph/GraphContainer.tsx b/public/app/features/explore/Graph/GraphContainer.tsx new file mode 100644 index 00000000000..cbbbbc8d166 --- /dev/null +++ b/public/app/features/explore/Graph/GraphContainer.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useState } from 'react'; + +import { DataFrame, EventBus, AbsoluteTimeRange, TimeZone, SplitOpen, LoadingState } from '@grafana/data'; +import { Collapse, useTheme2 } from '@grafana/ui'; +import { ExploreGraphStyle } from 'app/types'; + +import { storeGraphStyle } from '../state/utils'; + +import { ExploreGraph } from './ExploreGraph'; +import { ExploreGraphLabel } from './ExploreGraphLabel'; +import { loadGraphStyle } from './utils'; + +interface Props { + loading: boolean; + data: DataFrame[]; + annotations?: DataFrame[]; + eventBus: EventBus; + height: number; + width: number; + absoluteRange: AbsoluteTimeRange; + timeZone: TimeZone; + onChangeTime: (absoluteRange: AbsoluteTimeRange) => void; + splitOpenFn: SplitOpen; + loadingState: LoadingState; +} + +export const GraphContainer = ({ + loading, + data, + eventBus, + height, + width, + absoluteRange, + timeZone, + annotations, + onChangeTime, + splitOpenFn, + loadingState, +}: Props) => { + const [graphStyle, setGraphStyle] = useState(loadGraphStyle); + const theme = useTheme2(); + const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); + + const onGraphStyleChange = useCallback((graphStyle: ExploreGraphStyle) => { + storeGraphStyle(graphStyle); + setGraphStyle(graphStyle); + }, []); + + return ( + } + loading={loading} + isOpen + > + + + ); +}; diff --git a/public/app/features/explore/exploreGraphStyleUtils.ts b/public/app/features/explore/Graph/exploreGraphStyleUtils.ts similarity index 97% rename from public/app/features/explore/exploreGraphStyleUtils.ts rename to public/app/features/explore/Graph/exploreGraphStyleUtils.ts index 6da223c91b9..f48c746254e 100644 --- a/public/app/features/explore/exploreGraphStyleUtils.ts +++ b/public/app/features/explore/Graph/exploreGraphStyleUtils.ts @@ -2,8 +2,7 @@ import produce from 'immer'; import { FieldConfigSource } from '@grafana/data'; import { GraphDrawStyle, GraphFieldConfig, StackingMode } from '@grafana/schema'; - -import { ExploreGraphStyle } from '../../types'; +import { ExploreGraphStyle } from 'app/types'; export type FieldConfig = FieldConfigSource; diff --git a/public/app/features/explore/Graph/utils.ts b/public/app/features/explore/Graph/utils.ts new file mode 100644 index 00000000000..3b42e342c1f --- /dev/null +++ b/public/app/features/explore/Graph/utils.ts @@ -0,0 +1,26 @@ +import store from 'app/core/store'; +import { ExploreGraphStyle, EXPLORE_GRAPH_STYLES } from 'app/types'; + +const GRAPH_STYLE_KEY = 'grafana.explore.style.graph'; +export const storeGraphStyle = (graphStyle: string): void => { + store.set(GRAPH_STYLE_KEY, graphStyle); +}; + +export const loadGraphStyle = (): ExploreGraphStyle => { + return toGraphStyle(store.get(GRAPH_STYLE_KEY)); +}; + +const DEFAULT_GRAPH_STYLE: ExploreGraphStyle = 'lines'; +// we use this function to take any kind of data we loaded +// from an external source (URL, localStorage, whatever), +// and extract the graph-style from it, or return the default +// graph-style if we are not able to do that. +// it is important that this function is able to take any form of data, +// (be it objects, or arrays, or booleans or whatever), +// and produce a best-effort graphStyle. +// note that typescript makes sure we make no mistake in this function. +// we do not rely on ` as ` or ` any `. +export const toGraphStyle = (data: unknown): ExploreGraphStyle => { + const found = EXPLORE_GRAPH_STYLES.find((v) => v === data); + return found ?? DEFAULT_GRAPH_STYLE; +}; diff --git a/public/app/features/explore/LogsVolumePanel.test.tsx b/public/app/features/explore/LogsVolumePanel.test.tsx index 9b2970f106b..3dbb25728c4 100644 --- a/public/app/features/explore/LogsVolumePanel.test.tsx +++ b/public/app/features/explore/LogsVolumePanel.test.tsx @@ -5,7 +5,7 @@ import { DataQueryResponse, LoadingState, EventBusSrv } from '@grafana/data'; import { LogsVolumePanel } from './LogsVolumePanel'; -jest.mock('./ExploreGraph', () => { +jest.mock('./Graph/ExploreGraph', () => { const ExploreGraph = () => ExploreGraph; return { ExploreGraph, diff --git a/public/app/features/explore/LogsVolumePanel.tsx b/public/app/features/explore/LogsVolumePanel.tsx index be2a453de84..eecda2d8807 100644 --- a/public/app/features/explore/LogsVolumePanel.tsx +++ b/public/app/features/explore/LogsVolumePanel.tsx @@ -13,7 +13,7 @@ import { } from '@grafana/data'; import { Alert, Button, Collapse, InlineField, TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; -import { ExploreGraph } from './ExploreGraph'; +import { ExploreGraph } from './Graph/ExploreGraph'; type Props = { logsVolumeData: DataQueryResponse | undefined; @@ -125,7 +125,7 @@ export function LogsVolumePanel(props: Props) { loadingState={LoadingState.Done} data={logsVolumeData.data} height={height} - width={width - spacing} + width={width - spacing * 2} absoluteRange={range} onChangeTime={onUpdateTimeRange} timeZone={timeZone} diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index 58dc80a408e..ecbfa669b7f 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -24,7 +24,7 @@ import { } from 'app/core/utils/explore'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { ThunkResult } from 'app/types'; -import { ExploreGraphStyle, ExploreId, ExploreItemState } from 'app/types/explore'; +import { ExploreId, ExploreItemState } from 'app/types/explore'; import { datasourceReducer } from './datasource'; import { historyReducer } from './history'; @@ -36,7 +36,6 @@ import { loadAndInitDatasource, createEmptyQueryResponse, getUrlStateFromPaneState, - storeGraphStyle, } from './utils'; // Types @@ -118,20 +117,6 @@ export function changeSize( return changeSizeAction({ exploreId, height, width }); } -interface ChangeGraphStylePayload { - exploreId: ExploreId; - graphStyle: ExploreGraphStyle; -} - -const changeGraphStyleAction = createAction('explore/changeGraphStyle'); - -export function changeGraphStyle(exploreId: ExploreId, graphStyle: ExploreGraphStyle): ThunkResult { - return async (dispatch, getState) => { - storeGraphStyle(graphStyle); - dispatch(changeGraphStyleAction({ exploreId, graphStyle })); - }; -} - /** * Initialize Explore state with state from the URL and the React component. * Call this only on components for with the Explore state has not been initialized. @@ -281,11 +266,6 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac return { ...state, containerWidth }; } - if (changeGraphStyleAction.match(action)) { - const { graphStyle } = action.payload; - return { ...state, graphStyle }; - } - if (changePanelsStateAction.match(action)) { const { panelsState } = action.payload; return { ...state, panelsState }; diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index d4a4977253b..f980cadd52e 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -12,10 +12,10 @@ import { PanelData, } from '@grafana/data'; import { ExplorePanelData } from 'app/types'; -import { ExploreGraphStyle, ExploreItemState } from 'app/types/explore'; +import { ExploreItemState } from 'app/types/explore'; import store from '../../../core/store'; -import { clearQueryKeys, lastUsedDatasourceKeyForOrgId, toGraphStyle } from '../../../core/utils/explore'; +import { clearQueryKeys, lastUsedDatasourceKeyForOrgId } from '../../../core/utils/explore'; import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { SETTINGS_KEYS } from '../utils/logs'; import { toRawTimeRange } from '../utils/time'; @@ -30,11 +30,6 @@ export const storeGraphStyle = (graphStyle: string): void => { store.set(GRAPH_STYLE_KEY, graphStyle); }; -const loadGraphStyle = (): ExploreGraphStyle => { - const data = store.get(GRAPH_STYLE_KEY); - return toGraphStyle(data); -}; - const LOGS_VOLUME_ENABLED_KEY = SETTINGS_KEYS.enableVolumeHistogram; export const storeLogsVolumeEnabled = (enabled: boolean): void => { store.set(LOGS_VOLUME_ENABLED_KEY, enabled ? 'true' : 'false'); @@ -84,7 +79,6 @@ export const makeExplorePaneState = (): ExploreItemState => ({ logsVolumeEnabled: loadLogsVolumeEnabled(), logsVolumeDataProvider: undefined, logsVolumeData: undefined, - graphStyle: loadGraphStyle(), panelsState: {}, }); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index f0117e42106..439b39dac2c 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -187,8 +187,6 @@ export interface ExploreItemState { logsVolumeDataSubscription?: SubscriptionLike; logsVolumeData?: DataQueryResponse; - /* explore graph style */ - graphStyle: ExploreGraphStyle; panelsState: ExplorePanelsState; } From 8c585a4ebf570dba33dde779dddd345c1b83fd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 16 Nov 2022 11:36:30 +0100 Subject: [PATCH 253/926] Scene: Variables interpolation formats and multi value handling (#58591) * Component that can cache and extract variable dependencies * Component that can cache and extract variable dependencies * Updates * Refactoring * Lots of refactoring and iterations of supporting both re-rendering and query re-execution * Updated SceneCanvasText * Updated name of file * Updated * Refactoring a bit * Added back getName * Added comment * minor fix * Minor fix * Merge fixes * Scene variable interpolation progress * Merge fixes * Added all format registeries * Progress on multi value support * Progress on multi value support * Updates * Progress on scoped vars * Fixed circular dependency * Updates * Some review fixes * Updated comment * Added forceRender function * Add back fail on console log * Update public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts * Moving functions from SceneObjectBase * fixing tests * Fixed e2e Co-authored-by: Dominik Prokop --- .betterer.results | 6 +- .../load-options-from-url.spec.ts | 16 +- .../set-options-from-ui.spec.ts | 21 +- public/app/core/utils/kbn.ts | 2 +- .../scenes/components/SceneCanvasText.tsx | 3 +- .../scenes/components/ScenePanelRepeater.tsx | 3 +- .../scenes/components/SceneTimePicker.tsx | 3 +- .../features/scenes/components/VizPanel.tsx | 10 +- .../components/layout/SceneGridLayout.tsx | 3 +- .../scenes/core/SceneComponentWrapper.tsx | 26 +- .../features/scenes/core/SceneObjectBase.tsx | 101 +----- public/app/features/scenes/core/sceneGraph.ts | 120 +++++++ public/app/features/scenes/core/types.ts | 22 +- .../editor/SceneComponentEditWrapper.tsx | 9 +- .../scenes/editor/SceneEditManager.tsx | 5 + .../scenes/editor/SceneObjectTree.tsx | 3 +- .../scenes/querying/SceneQueryRunner.ts | 5 +- .../features/scenes/scenes/variablesDemo.tsx | 3 +- .../components/VariableValueSelect.tsx | 12 +- .../components/VariableValueSelectors.tsx | 3 +- .../interpolation/ScopedVarsVariable.ts | 72 ++++ .../interpolation/formatRegistry.test.ts | 68 ++++ .../variables/interpolation/formatRegistry.ts | 326 ++++++++++++++++++ .../interpolation/sceneInterpolator.test.ts | 149 ++++++++ .../interpolation/sceneInterpolator.ts | 128 +++++++ .../sceneTemplateInterpolator.test.ts | 63 ---- .../variables/sceneTemplateInterpolator.ts | 53 --- .../variables/sets/SceneVariableSet.test.tsx | 4 +- public/app/features/scenes/variables/types.ts | 9 +- .../variants/MultiValueVariable.test.ts | 107 +++++- .../variables/variants/MultiValueVariable.ts | 96 ++++-- .../variables/variants/TestVariable.tsx | 15 +- .../datasource/testdata/metricTree.test.ts | 4 +- .../plugins/datasource/testdata/metricTree.ts | 2 +- 34 files changed, 1157 insertions(+), 315 deletions(-) create mode 100644 public/app/features/scenes/core/sceneGraph.ts create mode 100644 public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts create mode 100644 public/app/features/scenes/variables/interpolation/formatRegistry.test.ts create mode 100644 public/app/features/scenes/variables/interpolation/formatRegistry.ts create mode 100644 public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts create mode 100644 public/app/features/scenes/variables/interpolation/sceneInterpolator.ts delete mode 100644 public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts delete mode 100644 public/app/features/scenes/variables/sceneTemplateInterpolator.ts diff --git a/.betterer.results b/.betterer.results index fe3efee0f46..bfb01521da9 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4569,13 +4569,15 @@ exports[`better eslint`] = { "public/app/features/scenes/core/SceneObjectBase.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/features/scenes/core/sceneGraph.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/scenes/core/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/e2e/dashboards-suite/load-options-from-url.spec.ts b/e2e/dashboards-suite/load-options-from-url.spec.ts index bb7f1d7bf20..4c03e37675b 100644 --- a/e2e/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e/dashboards-suite/load-options-from-url.spec.ts @@ -20,7 +20,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -33,7 +33,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -46,7 +46,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -72,7 +72,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -85,7 +85,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -98,7 +98,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -135,7 +135,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -147,7 +147,7 @@ describe('Variables - Load options from Url', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 10); + e2e().get('.variable-option').should('have.length', 65); }); }); }); diff --git a/e2e/dashboards-suite/set-options-from-ui.spec.ts b/e2e/dashboards-suite/set-options-from-ui.spec.ts index 9449ce8fb7d..cacbb1dd007 100644 --- a/e2e/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e/dashboards-suite/set-options-from-ui.spec.ts @@ -27,7 +27,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -42,19 +42,16 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 10); + e2e().get('.variable-option').should('have.length', 65); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAA').should('be.visible'); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAB').should('be.visible'); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAC').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBB').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBC').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCA').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCB').should('be.visible'); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BCC').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAD').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAE').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BAF').should('be.visible'); }); it('adding a value that is not part of dependents options should add the new values dependant options', () => { @@ -81,7 +78,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 7); + e2e().get('.variable-option').should('have.length', 17); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -97,7 +94,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -132,7 +129,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('All').should('be.visible'); @@ -145,7 +142,7 @@ describe('Variables - Set options from ui', () => { e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownDropDown() .should('be.visible') .within(() => { - e2e().get('.variable-option').should('have.length', 4); + e2e().get('.variable-option').should('have.length', 9); }); e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('BBA').should('be.visible'); diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 1921244088a..e09f153c960 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -24,7 +24,7 @@ const kbn = { s: 1, ms: 0.001, } as { [index: string]: number }, - regexEscape: (value: string) => value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'), + regexEscape: (value: string): string => value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&'), /** @deprecated since 7.2, use grafana/data */ roundInterval: (interval: number) => { diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx index b76e1e748d4..6552a45b28a 100644 --- a/public/app/features/scenes/components/SceneCanvasText.tsx +++ b/public/app/features/scenes/components/SceneCanvasText.tsx @@ -3,6 +3,7 @@ import React, { CSSProperties } from 'react'; import { Field, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; @@ -31,7 +32,7 @@ export class SceneCanvasText extends SceneObjectBase { return (
    - {model.interpolate(text)} + {sceneGraph.interpolate(model, text)}
    ); }; diff --git a/public/app/features/scenes/components/ScenePanelRepeater.tsx b/public/app/features/scenes/components/ScenePanelRepeater.tsx index 26063a64211..42a18fd8832 100644 --- a/public/app/features/scenes/components/ScenePanelRepeater.tsx +++ b/public/app/features/scenes/components/ScenePanelRepeater.tsx @@ -4,6 +4,7 @@ import { LoadingState, PanelData } from '@grafana/data'; import { SceneDataNode } from '../core/SceneDataNode'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneObject, @@ -21,7 +22,7 @@ export class ScenePanelRepeater extends SceneObjectBase { super.activate(); this._subs.add( - this.getData().subscribeToState({ + sceneGraph.getData(this).subscribeToState({ next: (data) => { if (data.data?.state === LoadingState.Done) { this.performRepeat(data.data); diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx index ad6b8281d9d..d850f0e769a 100644 --- a/public/app/features/scenes/components/SceneTimePicker.tsx +++ b/public/app/features/scenes/components/SceneTimePicker.tsx @@ -4,6 +4,7 @@ import { RefreshPicker, ToolbarButtonRow } from '@grafana/ui'; import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneObjectStatePlain } from '../core/types'; export interface SceneTimePickerState extends SceneObjectStatePlain { @@ -16,7 +17,7 @@ export class SceneTimePicker extends SceneObjectBase { function SceneTimePickerRenderer({ model }: SceneComponentProps) { const { hidePicker } = model.useState(); - const timeRange = model.getTimeRange(); + const timeRange = sceneGraph.getTimeRange(model); const timeRangeState = timeRange.useState(); if (hidePicker) { diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx index 3882a898552..2ffb21bfd90 100644 --- a/public/app/features/scenes/components/VizPanel.tsx +++ b/public/app/features/scenes/components/VizPanel.tsx @@ -6,6 +6,7 @@ import { PanelRenderer } from '@grafana/runtime'; import { Field, PanelChrome, Input } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; @@ -27,7 +28,7 @@ export class VizPanel extends SceneObjectBase { }); public onSetTimeRange = (timeRange: AbsoluteTimeRange) => { - const sceneTimeRange = this.getTimeRange(); + const sceneTimeRange = sceneGraph.getTimeRange(this); sceneTimeRange.setState({ raw: { from: toUtc(timeRange.from), @@ -41,12 +42,13 @@ export class VizPanel extends SceneObjectBase { function ScenePanelRenderer({ model }: SceneComponentProps) { const { title, pluginId, options, fieldConfig, ...state } = model.useState(); - const { data } = model.getData().useState(); - const layout = model.getLayout(); + const { data } = sceneGraph.getData(model).useState(); + + const layout = sceneGraph.getLayout(model); const isDraggable = layout.state.isDraggable ? state.isDraggable : false; const dragHandle = ; - const titleInterpolated = model.interpolate(title); + const titleInterpolated = sceneGraph.interpolate(model, title); return ( diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.tsx index 645a110bcec..6e70b2afd31 100644 --- a/public/app/features/scenes/components/layout/SceneGridLayout.tsx +++ b/public/app/features/scenes/components/layout/SceneGridLayout.tsx @@ -8,6 +8,7 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { DEFAULT_PANEL_SPAN, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { sceneGraph } from '../../core/sceneGraph'; import { SceneComponentProps, SceneLayoutChild, @@ -411,7 +412,7 @@ export class SceneGridRow extends SceneObjectBase { function SceneGridRowRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getSceneGridRowStyles); const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); - const layout = model.getLayout(); + const layout = sceneGraph.getLayout(model); const dragHandle = ; return ( diff --git a/public/app/features/scenes/core/SceneComponentWrapper.tsx b/public/app/features/scenes/core/SceneComponentWrapper.tsx index 6064c8f2dba..caf04343783 100644 --- a/public/app/features/scenes/core/SceneComponentWrapper.tsx +++ b/public/app/features/scenes/core/SceneComponentWrapper.tsx @@ -1,8 +1,6 @@ import React, { useEffect } from 'react'; -import { SceneComponentEditingWrapper } from '../editor/SceneComponentEditWrapper'; - -import { SceneComponentProps, SceneObject } from './types'; +import { SceneComponentProps, SceneEditor, SceneObject } from './types'; export function SceneComponentWrapper({ model, @@ -32,9 +30,29 @@ export function SceneComponentWrapper({ return inner; } - return {inner}; + const editor = getSceneEditor(model); + const EditWrapper = getSceneEditor(model).getEditComponentWrapper(); + + return ( + + {inner} + + ); } function EmptyRenderer(_: SceneComponentProps): React.ReactElement | null { return null; } + +function getSceneEditor(sceneObject: SceneObject): SceneEditor { + const { $editor } = sceneObject.state; + if ($editor) { + return $editor; + } + + if (sceneObject.parent) { + return getSceneEditor(sceneObject.parent); + } + + throw new Error('No editor found in scene tree'); +} diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index 9ba3bfa1b0d..f0c5f75b27f 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -5,20 +5,11 @@ import { v4 as uuidv4 } from 'uuid'; import { BusEvent, BusEventHandler, BusEventType, EventBusSrv } from '@grafana/data'; import { useForceUpdate } from '@grafana/ui'; -import { sceneTemplateInterpolator } from '../variables/sceneTemplateInterpolator'; -import { SceneVariables, SceneVariableDependencyConfigLike } from '../variables/types'; +import { SceneVariableDependencyConfigLike } from '../variables/types'; import { SceneComponentWrapper } from './SceneComponentWrapper'; import { SceneObjectStateChangedEvent } from './events'; -import { - SceneDataState, - SceneObject, - SceneComponent, - SceneEditor, - SceneTimeRange, - SceneObjectState, - SceneLayoutState, -} from './types'; +import { SceneObject, SceneComponent, SceneObjectState } from './types'; import { cloneSceneObject, forEachSceneObjectInState } from './utils'; export abstract class SceneObjectBase @@ -185,81 +176,6 @@ export abstract class SceneObjectBase { - const { $data } = this.state; - if ($data) { - return $data; - } - - if (this.parent) { - return this.parent.getData(); - } - - throw new Error('No data found in scene tree'); - } - - public getVariables(): SceneVariables | undefined { - if (this.state.$variables) { - return this.state.$variables; - } - - if (this.parent) { - return this.parent.getVariables(); - } - - return undefined; - } - - /** - * Will walk up the scene object graph to the closest $layout scene object - */ - public getLayout(): SceneObject { - if (this.constructor.name === 'SceneFlexLayout' || this.constructor.name === 'SceneGridLayout') { - return this as SceneObject; - } - - if (this.parent) { - return this.parent.getLayout(); - } - - throw new Error('No layout found in scene tree'); - } - - /** - * Will walk up the scene object graph to the closest $editor scene object - */ - public getSceneEditor(): SceneEditor { - const { $editor } = this.state; - if ($editor) { - return $editor; - } - - if (this.parent) { - return this.parent.getSceneEditor(); - } - - throw new Error('No editor found in scene tree'); - } - /** Force a re-render, should only be needed when variable values change */ public forceRender(): void { this.setState({}); @@ -271,19 +187,6 @@ export abstract class SceneObjectBase): this { return cloneSceneObject(this, withState); } - - /** - * Interpolates the given string using the current scene object as context. - * TODO: Cache interpolatinos? - */ - public interpolate(value: string | undefined) { - // Skip interpolation if there are no variable depdendencies - if (!value || !this._variableDependency || this._variableDependency.getNames().size === 0) { - return value; - } - - return sceneTemplateInterpolator(value, this); - } } /** diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts new file mode 100644 index 00000000000..6cd7e4606d4 --- /dev/null +++ b/public/app/features/scenes/core/sceneGraph.ts @@ -0,0 +1,120 @@ +import { getDefaultTimeRange, LoadingState } from '@grafana/data'; + +import { sceneInterpolator } from '../variables/interpolation/sceneInterpolator'; +import { SceneVariableSet } from '../variables/sets/SceneVariableSet'; +import { SceneVariables } from '../variables/types'; + +import { SceneDataNode } from './SceneDataNode'; +import { SceneTimeRange as SceneTimeRangeImpl } from './SceneTimeRange'; +import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRange } from './types'; + +/** + * Get the closest node with variables + */ +export function getVariables(sceneObject: SceneObject): SceneVariables { + if (sceneObject.state.$variables) { + return sceneObject.state.$variables; + } + + if (sceneObject.parent) { + return getVariables(sceneObject.parent); + } + + return EmptyVariableSet; +} + +/** + * Will walk up the scene object graph to the closest $data scene object + */ +export function getData(sceneObject: SceneObject): SceneObject { + const { $data } = sceneObject.state; + if ($data) { + return $data; + } + + if (sceneObject.parent) { + return getData(sceneObject.parent); + } + + return EmptyDataNode; +} + +/** + * Will walk up the scene object graph to the closest $timeRange scene object + */ +export function getTimeRange(sceneObject: SceneObject): SceneTimeRange { + const { $timeRange } = sceneObject.state; + if ($timeRange) { + return $timeRange; + } + + if (sceneObject.parent) { + return getTimeRange(sceneObject.parent); + } + + return DefaultTimeRange; +} + +/** + * Will walk up the scene object graph to the closest $editor scene object + */ +export function getSceneEditor(sceneObject: SceneObject): SceneEditor { + const { $editor } = sceneObject.state; + if ($editor) { + return $editor; + } + + if (sceneObject.parent) { + return getSceneEditor(sceneObject.parent); + } + + throw new Error('No editor found in scene tree'); +} + +/** + * Will walk up the scene object graph to the closest $layout scene object + */ +export function getLayout(scene: SceneObject): SceneObject { + if (scene.constructor.name === 'SceneFlexLayout' || scene.constructor.name === 'SceneGridLayout') { + return scene as SceneObject; + } + + if (scene.parent) { + return getLayout(scene.parent); + } + + throw new Error('No layout found in scene tree'); +} + +/** + * Interpolates the given string using the current scene object as context. * + */ +export function interpolate(sceneObject: SceneObject, value: string | undefined | null): string { + // Skip interpolation if there are no variable dependencies + if (!value || !sceneObject.variableDependency || sceneObject.variableDependency.getNames().size === 0) { + return value ?? ''; + } + + return sceneInterpolator(sceneObject, value); +} + +export const EmptyVariableSet = new SceneVariableSet({ variables: [] }); + +export const EmptyDataNode = new SceneDataNode({ + data: { + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }, +}); + +export const DefaultTimeRange = new SceneTimeRangeImpl(getDefaultTimeRange()); + +export const sceneGraph = { + getVariables, + getData, + getTimeRange, + getSceneEditor, + getLayout, + interpolate, +}; diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index f99d764c064..d818e5bba4b 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -86,24 +86,9 @@ export interface SceneObject /** Called when component unmounts. Unsubscribe and closes all subscriptions */ deactivate(): void; - /** Get the scene editor */ - getSceneEditor(): SceneEditor; - /** Get the scene root */ getRoot(): SceneObject; - /** Get the closest node with data */ - getData(): SceneObject; - - /** Get the closest node with variables */ - getVariables(): SceneVariables | undefined; - - /** Get the closest node with time range */ - getTimeRange(): SceneTimeRange; - - /** Get the closest layout node */ - getLayout(): SceneObject; - /** Returns a deep clone this object and all its children */ clone(state?: Partial): this; @@ -134,6 +119,13 @@ export interface SceneEditor extends SceneObject { onMouseEnterObject(model: SceneObject): void; onMouseLeaveObject(model: SceneObject): void; onSelectObject(model: SceneObject): void; + getEditComponentWrapper(): React.ComponentType; +} + +interface SceneComponentEditWrapperProps { + editor: SceneEditor; + model: SceneObject; + children: React.ReactNode; } export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {} diff --git a/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx b/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx index 5049e06f893..eaa6a77d274 100644 --- a/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx +++ b/public/app/features/scenes/editor/SceneComponentEditWrapper.tsx @@ -4,17 +4,18 @@ import React, { CSSProperties } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -import { SceneObject } from '../core/types'; +import { SceneEditor, SceneObject } from '../core/types'; -export function SceneComponentEditingWrapper({ +export function SceneComponentEditWrapper({ model, + editor, children, }: { - model: T; + model: SceneObject; + editor: SceneEditor; children: React.ReactNode; }) { const styles = useStyles2(getStyles); - const editor = model.getSceneEditor(); const { hoverObject, selectedObject } = editor.useState(); const onMouseEnter = () => editor.onMouseEnterObject(model); diff --git a/public/app/features/scenes/editor/SceneEditManager.tsx b/public/app/features/scenes/editor/SceneEditManager.tsx index f7fea9e0bc9..ba40ce6975a 100644 --- a/public/app/features/scenes/editor/SceneEditManager.tsx +++ b/public/app/features/scenes/editor/SceneEditManager.tsx @@ -7,6 +7,7 @@ import { useStyles2 } from '@grafana/ui'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneEditorState, SceneEditor, SceneObject, SceneComponentProps, SceneComponent } from '../core/types'; +import { SceneComponentEditWrapper } from './SceneComponentEditWrapper'; import { SceneObjectEditor } from './SceneObjectEditor'; import { SceneObjectTree } from './SceneObjectTree'; @@ -32,6 +33,10 @@ export class SceneEditManager extends SceneObjectBase implemen public onSelectObject(model: SceneObject) { this.setState({ selectedObject: { ref: model } }); } + + public getEditComponentWrapper() { + return SceneComponentEditWrapper; + } } function SceneEditorRenderer({ model, isEditing }: SceneComponentProps) { diff --git a/public/app/features/scenes/editor/SceneObjectTree.tsx b/public/app/features/scenes/editor/SceneObjectTree.tsx index c167300afae..bd612a6e172 100644 --- a/public/app/features/scenes/editor/SceneObjectTree.tsx +++ b/public/app/features/scenes/editor/SceneObjectTree.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, useStyles2 } from '@grafana/ui'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneObject, isSceneObject, SceneLayoutChild } from '../core/types'; export interface Props { @@ -31,7 +32,7 @@ export function SceneObjectTree({ node, selectedObject }: Props) { const name = node.constructor.name; const isSelected = selectedObject === node; - const onSelectNode = () => node.getSceneEditor().onSelectObject(node); + const onSelectNode = () => sceneGraph.getSceneEditor(node).onSelectObject(node); return (
    diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index 01bf47e1fc0..ba9312f6c43 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -17,6 +17,7 @@ import { getNextRequestId } from 'app/features/query/state/PanelQueryRunner'; import { runRequest } from 'app/features/query/state/runRequest'; import { SceneObjectBase } from '../core/SceneObjectBase'; +import { sceneGraph } from '../core/sceneGraph'; import { SceneObjectStatePlain } from '../core/types'; import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; @@ -40,7 +41,7 @@ export class SceneQueryRunner extends SceneObjectBase { public activate() { super.activate(); - const timeRange = this.getTimeRange(); + const timeRange = sceneGraph.getTimeRange(this); this._subs.add( timeRange.subscribeToState({ @@ -65,7 +66,7 @@ export class SceneQueryRunner extends SceneObjectBase { } public runQueries() { - const timeRange = this.getTimeRange(); + const timeRange = sceneGraph.getTimeRange(this); this.runWithTimeRange(timeRange.state); } diff --git a/public/app/features/scenes/scenes/variablesDemo.tsx b/public/app/features/scenes/scenes/variablesDemo.tsx index 1f2e8d93663..a005d6796c1 100644 --- a/public/app/features/scenes/scenes/variablesDemo.tsx +++ b/public/app/features/scenes/scenes/variablesDemo.tsx @@ -31,6 +31,7 @@ export function getVariablesDemo(): Scene { query: 'A.$server.*', value: 'pod', delayMs: 1000, + isMulti: true, text: '', options: [], }), @@ -59,7 +60,7 @@ export function getVariablesDemo(): Scene { }), new SceneCanvasText({ size: { width: '40%' }, - text: 'server - pod: ${server} - ${pod}', + text: 'server: ${server} pod:${pod}', fontSize: 20, align: 'center', }), diff --git a/public/app/features/scenes/variables/components/VariableValueSelect.tsx b/public/app/features/scenes/variables/components/VariableValueSelect.tsx index b70dcc28920..c746fa9988c 100644 --- a/public/app/features/scenes/variables/components/VariableValueSelect.tsx +++ b/public/app/features/scenes/variables/components/VariableValueSelect.tsx @@ -19,7 +19,13 @@ export function VariableValueSelect({ model }: SceneComponentProps { + model.changeValueTo( + newValue.map((v) => v.value!), + newValue.map((v) => v.label!) + ); + }} /> ); } @@ -33,7 +39,9 @@ export function VariableValueSelect({ model }: SceneComponentProps { + model.changeValueTo(newValue.value!, newValue.label!); + }} /> ); } diff --git a/public/app/features/scenes/variables/components/VariableValueSelectors.tsx b/public/app/features/scenes/variables/components/VariableValueSelectors.tsx index 2758d612ea5..6029e20096c 100644 --- a/public/app/features/scenes/variables/components/VariableValueSelectors.tsx +++ b/public/app/features/scenes/variables/components/VariableValueSelectors.tsx @@ -5,6 +5,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Tooltip } from '@grafana/ui'; import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { sceneGraph } from '../../core/sceneGraph'; import { SceneComponentProps, SceneObject, SceneObjectStatePlain } from '../../core/types'; import { SceneVariableState } from '../types'; @@ -13,7 +14,7 @@ export class VariableValueSelectors extends SceneObjectBase) { - const variables = model.getVariables()!.useState(); + const variables = sceneGraph.getVariables(model)!.useState(); return ( <> diff --git a/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts new file mode 100644 index 00000000000..ff555e5a9bf --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts @@ -0,0 +1,72 @@ +import { property } from 'lodash'; + +import { ScopedVar } from '@grafana/data'; + +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { SceneVariable, SceneVariableState, VariableValue } from '../types'; + +export interface ScopedVarsProxyVariableState extends SceneVariableState { + value: ScopedVar; +} + +export class ScopedVarsVariable + extends SceneObjectBase + implements SceneVariable +{ + private static fieldAccessorCache: FieldAccessorCache = {}; + + public getValue(fieldPath: string): VariableValue { + let { value } = this.state; + let realValue = value.value; + + if (fieldPath) { + realValue = this.getFieldAccessor(fieldPath)(value.value); + } else { + realValue = value.value; + } + + if (realValue === 'string' || realValue === 'number' || realValue === 'boolean') { + return realValue; + } + + return String(realValue); + } + + public getValueText(): string { + const { value } = this.state; + + if (value.text != null) { + return String(value.text); + } + + return String(value); + } + + private getFieldAccessor(fieldPath: string) { + const accessor = ScopedVarsVariable.fieldAccessorCache[fieldPath]; + if (accessor) { + return accessor; + } + + return (ScopedVarsVariable.fieldAccessorCache[fieldPath] = property(fieldPath)); + } +} + +interface FieldAccessorCache { + [key: string]: (obj: unknown) => unknown; +} + +let scopedVarsVariable: ScopedVarsVariable | undefined; + +/** + * Reuses a single instance to avoid unnecessary memory allocations + */ +export function getSceneVariableForScopedVar(name: string, value: ScopedVar) { + if (!scopedVarsVariable) { + scopedVarsVariable = new ScopedVarsVariable({ name, value }); + } else { + scopedVarsVariable.setState({ name, value }); + } + + return scopedVarsVariable; +} diff --git a/public/app/features/scenes/variables/interpolation/formatRegistry.test.ts b/public/app/features/scenes/variables/interpolation/formatRegistry.test.ts new file mode 100644 index 00000000000..6847489c135 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/formatRegistry.test.ts @@ -0,0 +1,68 @@ +import { VariableValue } from '../types'; +import { TestVariable } from '../variants/TestVariable'; + +import { formatRegistry, FormatRegistryID } from './formatRegistry'; + +function formatValue( + formatId: FormatRegistryID, + value: T, + text?: string, + args: string[] = [] +): string { + const variable = new TestVariable({ name: 'server', value, text }); + return formatRegistry.get(formatId).formatter(value, args, variable); +} + +describe('formatRegistry', () => { + it('Can format values acccording to format', () => { + expect(formatValue(FormatRegistryID.lucene, 'foo bar')).toBe('foo\\ bar'); + expect(formatValue(FormatRegistryID.lucene, '-1')).toBe('-1'); + expect(formatValue(FormatRegistryID.lucene, '-test')).toBe('\\-test'); + expect(formatValue(FormatRegistryID.lucene, ['foo bar', 'baz'])).toBe('("foo\\ bar" OR "baz")'); + expect(formatValue(FormatRegistryID.lucene, [])).toBe('__empty__'); + + expect(formatValue(FormatRegistryID.glob, 'foo')).toBe('foo'); + expect(formatValue(FormatRegistryID.glob, ['AA', 'BB', 'C.*'])).toBe('{AA,BB,C.*}'); + + expect(formatValue(FormatRegistryID.text, 'v', 'display text')).toBe('display text'); + + expect(formatValue(FormatRegistryID.raw, [12, 13])).toBe('12,13'); + expect(formatValue(FormatRegistryID.raw, '#Ƴ ̇¹"Ä1"#!"#!½')).toBe('#Ƴ ̇¹"Ä1"#!"#!½'); + + expect(formatValue(FormatRegistryID.regex, 'test.')).toBe('test\\.'); + expect(formatValue(FormatRegistryID.regex, ['test.'])).toBe('test\\.'); + expect(formatValue(FormatRegistryID.regex, ['test.', 'test2'])).toBe('(test\\.|test2)'); + + expect(formatValue(FormatRegistryID.pipe, ['test', 'test2'])).toBe('test|test2'); + + expect(formatValue(FormatRegistryID.distributed, ['test'])).toBe('test'); + expect(formatValue(FormatRegistryID.distributed, ['test', 'test2'])).toBe('test,server=test2'); + + expect(formatValue(FormatRegistryID.csv, 'test')).toBe('test'); + expect(formatValue(FormatRegistryID.csv, ['test', 'test2'])).toBe('test,test2'); + + expect(formatValue(FormatRegistryID.html, '')).toBe( + '<script>alert(asd)</script>' + ); + + expect(formatValue(FormatRegistryID.json, ['test', 12])).toBe('["test",12]'); + + expect(formatValue(FormatRegistryID.percentEncode, ['foo()bar BAZ', 'test2'])).toBe( + '%7Bfoo%28%29bar%20BAZ%2Ctest2%7D' + ); + + expect(formatValue(FormatRegistryID.singleQuote, 'test')).toBe(`'test'`); + expect(formatValue(FormatRegistryID.singleQuote, ['test', `test'2`])).toBe("'test','test\\'2'"); + + expect(formatValue(FormatRegistryID.doubleQuote, 'test')).toBe(`"test"`); + expect(formatValue(FormatRegistryID.doubleQuote, ['test', `test"2`])).toBe('"test","test\\"2"'); + + expect(formatValue(FormatRegistryID.sqlString, "test'value")).toBe(`'test''value'`); + expect(formatValue(FormatRegistryID.sqlString, ['test', "test'value2"])).toBe(`'test','test''value2'`); + + expect(formatValue(FormatRegistryID.date, 1594671549254)).toBe('2020-07-13T20:19:09.254Z'); + expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['seconds'])).toBe('1594671549'); + expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['iso'])).toBe('2020-07-13T20:19:09.254Z'); + expect(formatValue(FormatRegistryID.date, 1594671549254, 'text', ['YYYY-MM'])).toBe('2020-07'); + }); +}); diff --git a/public/app/features/scenes/variables/interpolation/formatRegistry.ts b/public/app/features/scenes/variables/interpolation/formatRegistry.ts new file mode 100644 index 00000000000..e216ee58418 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/formatRegistry.ts @@ -0,0 +1,326 @@ +import { isArray, map, replace } from 'lodash'; + +import { dateTime, Registry, RegistryItem, textUtil } from '@grafana/data'; +import kbn from 'app/core/utils/kbn'; +import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; + +import { SceneVariable, VariableValue, VariableValueSingle } from '../types'; + +export interface FormatRegistryItem extends RegistryItem { + formatter(value: VariableValue, args: string[], variable: SceneVariable): string; +} + +export enum FormatRegistryID { + lucene = 'lucene', + raw = 'raw', + regex = 'regex', + pipe = 'pipe', + distributed = 'distributed', + csv = 'csv', + html = 'html', + json = 'json', + percentEncode = 'percentencode', + singleQuote = 'singlequote', + doubleQuote = 'doublequote', + sqlString = 'sqlstring', + date = 'date', + glob = 'glob', + text = 'text', + queryParam = 'queryparam', +} + +export const formatRegistry = new Registry(() => { + const formats: FormatRegistryItem[] = [ + { + id: FormatRegistryID.lucene, + name: 'Lucene', + description: 'Values are lucene escaped and multi-valued variables generate an OR expression', + formatter: (value) => { + if (typeof value === 'string') { + return luceneEscape(value); + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '__empty__'; + } + const quotedValues = map(value, (val: string) => { + return '"' + luceneEscape(val) + '"'; + }); + return '(' + quotedValues.join(' OR ') + ')'; + } else { + return luceneEscape(`${value}`); + } + }, + }, + { + id: FormatRegistryID.raw, + name: 'raw', + description: 'Keep value as is', + formatter: (value) => String(value), + }, + { + id: FormatRegistryID.regex, + name: 'Regex', + description: 'Values are regex escaped and multi-valued variables generate a (|) expression', + formatter: (value) => { + if (typeof value === 'string') { + return kbn.regexEscape(value); + } + + if (Array.isArray(value)) { + const escapedValues = value.map((item) => { + if (typeof item === 'string') { + return kbn.regexEscape(item); + } else { + return kbn.regexEscape(String(item)); + } + }); + + if (escapedValues.length === 1) { + return escapedValues[0]; + } + + return '(' + escapedValues.join('|') + ')'; + } + + return kbn.regexEscape(`${value}`); + }, + }, + { + id: FormatRegistryID.pipe, + name: 'Pipe', + description: 'Values are separated by | character', + formatter: (value) => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value.join('|'); + } + + return `${value}`; + }, + }, + { + id: FormatRegistryID.distributed, + name: 'Distributed', + description: 'Multiple values are formatted like variable=value', + formatter: (value, args, variable) => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + value = map(value, (val: string, index: number) => { + if (index !== 0) { + return variable.state.name + '=' + val; + } else { + return val; + } + }); + + return value.join(','); + } + + return `${value}`; + }, + }, + { + id: FormatRegistryID.csv, + name: 'Csv', + description: 'Comma-separated values', + formatter: (value) => { + if (typeof value === 'string') { + return value; + } + + if (isArray(value)) { + return value.join(','); + } + + return String(value); + }, + }, + { + id: FormatRegistryID.html, + name: 'HTML', + description: 'HTML escaping of values', + formatter: (value) => { + if (typeof value === 'string') { + return textUtil.escapeHtml(value); + } + + if (isArray(value)) { + return textUtil.escapeHtml(value.join(', ')); + } + + return textUtil.escapeHtml(String(value)); + }, + }, + { + id: FormatRegistryID.json, + name: 'JSON', + description: 'JSON stringify value', + formatter: (value) => { + return JSON.stringify(value); + }, + }, + { + id: FormatRegistryID.percentEncode, + name: 'Percent encode', + description: 'Useful for URL escaping values', + formatter: (value) => { + // like glob, but url escaped + if (isArray(value)) { + return encodeURIComponentStrict('{' + value.join(',') + '}'); + } + + return encodeURIComponentStrict(value); + }, + }, + { + id: FormatRegistryID.singleQuote, + name: 'Single quote', + description: 'Single quoted values', + formatter: (value) => { + // escape single quotes with backslash + const regExp = new RegExp(`'`, 'g'); + + if (isArray(value)) { + return map(value, (v: string) => `'${replace(v, regExp, `\\'`)}'`).join(','); + } + + let strVal = typeof value === 'string' ? value : String(value); + return `'${replace(strVal, regExp, `\\'`)}'`; + }, + }, + { + id: FormatRegistryID.doubleQuote, + name: 'Double quote', + description: 'Double quoted values', + formatter: (value) => { + // escape double quotes with backslash + const regExp = new RegExp('"', 'g'); + if (isArray(value)) { + return map(value, (v: string) => `"${replace(v, regExp, '\\"')}"`).join(','); + } + + let strVal = typeof value === 'string' ? value : String(value); + return `"${replace(strVal, regExp, '\\"')}"`; + }, + }, + { + id: FormatRegistryID.sqlString, + name: 'SQL string', + description: 'SQL string quoting and commas for use in IN statements and other scenarios', + formatter: (value) => { + // escape single quotes by pairing them + const regExp = new RegExp(`'`, 'g'); + if (isArray(value)) { + return map(value, (v: string) => `'${replace(v, regExp, "''")}'`).join(','); + } + + let strVal = typeof value === 'string' ? value : String(value); + return `'${replace(strVal, regExp, "''")}'`; + }, + }, + { + id: FormatRegistryID.date, + name: 'Date', + description: 'Format date in different ways', + formatter: (value, args) => { + let nrValue = 0; + + if (typeof value === 'number') { + nrValue = value; + } else if (typeof value === 'string') { + nrValue = parseInt(value, 10); + } else { + return ''; + } + + const arg = args[0] ?? 'iso'; + switch (arg) { + case 'ms': + return String(value); + case 'seconds': + return `${Math.round(nrValue! / 1000)}`; + case 'iso': + return dateTime(nrValue).toISOString(); + default: + return dateTime(nrValue).format(arg); + } + }, + }, + { + id: FormatRegistryID.glob, + name: 'Glob', + description: 'Format multi-valued variables using glob syntax, example {value1,value2}', + formatter: (value) => { + if (isArray(value) && value.length > 1) { + return '{' + value.join(',') + '}'; + } + return String(value); + }, + }, + { + id: FormatRegistryID.text, + name: 'Text', + description: 'Format variables in their text representation. Example in multi-variable scenario A + B + C.', + formatter: (value, _args, variable) => { + // if (typeof options.text === 'string') { + // return options.value === ALL_VARIABLE_VALUE ? ALL_VARIABLE_TEXT : options.text; + // } + + if (variable.getValueText) { + return variable.getValueText(); + } + + return String(value); + }, + }, + { + id: FormatRegistryID.queryParam, + name: 'Query parameter', + description: + 'Format variables as URL parameters. Example in multi-variable scenario A + B + C => var-foo=A&var-foo=B&var-foo=C.', + formatter: (value, _args, variable) => { + if (Array.isArray(value)) { + return value.map((v) => formatQueryParameter(variable.state.name, v)).join('&'); + } + return formatQueryParameter(variable.state.name, value); + }, + }, + ]; + + return formats; +}); + +function luceneEscape(value: string) { + if (isNaN(+value) === false) { + return value; + } + + return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1'); +} + +/** + * encode string according to RFC 3986; in contrast to encodeURIComponent() + * also the sub-delims "!", "'", "(", ")" and "*" are encoded; + * unicode handling uses UTF-8 as in ECMA-262. + */ +function encodeURIComponentStrict(str: VariableValueSingle) { + return encodeURIComponent(str).replace(/[!'()*]/g, (c) => { + return '%' + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +function formatQueryParameter(name: string, value: VariableValueSingle): string { + return `var-${name}=${encodeURIComponentStrict(value)}`; +} + +export function isAllValue(value: VariableValueSingle) { + return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); +} diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts new file mode 100644 index 00000000000..ac99a9e38c5 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.test.ts @@ -0,0 +1,149 @@ +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { SceneObjectStatePlain } from '../../core/types'; +import { SceneVariableSet } from '../sets/SceneVariableSet'; +import { ConstantVariable } from '../variants/ConstantVariable'; +import { ObjectVariable } from '../variants/ObjectVariable'; +import { TestVariable } from '../variants/TestVariable'; + +import { sceneInterpolator } from './sceneInterpolator'; + +interface TestSceneState extends SceneObjectStatePlain { + nested?: TestScene; +} + +class TestScene extends SceneObjectBase {} + +describe('sceneInterpolator', () => { + it('Should be interpolated and use closest variable', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: 'test', + value: 'hello', + }), + new ConstantVariable({ + name: 'atRootOnly', + value: 'RootValue', + }), + ], + }), + nested: new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: 'test', + value: 'nestedValue', + }), + ], + }), + }), + }); + + expect(sceneInterpolator(scene, '${test}')).toBe('hello'); + expect(sceneInterpolator(scene.state.nested!, '${test}')).toBe('nestedValue'); + expect(sceneInterpolator(scene.state.nested!, '${atRootOnly}')).toBe('RootValue'); + }); + + describe('Given an expression with fieldPath', () => { + it('Should interpolate correctly', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ObjectVariable({ + name: 'test', + value: { prop1: 'prop1Value' }, + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test.prop1}')).toBe('prop1Value'); + }); + }); + + it('Can use format', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: 'test', + value: 'hello', + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test:queryparam}')).toBe('var-test=hello'); + }); + + it('Can format multi valued values', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'test', + value: ['hello', 'world'], + }), + ], + }), + }); + + expect(sceneInterpolator(scene, 'test.${test}.asd')).toBe('test.{hello,world}.asd'); + }); + + it('Can format multi valued values using text formatter', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'test', + value: ['1', '2'], + text: ['hello', 'world'], + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test:text}')).toBe('hello + world'); + }); + + it('Can use formats with arguments', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [ + new TestVariable({ + name: 'test', + value: 1594671549254, + }), + ], + }), + }); + + expect(sceneInterpolator(scene, '${test:date:YYYY-MM}')).toBe('2020-07'); + }); + + it('Can use scopedVars', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [], + }), + }); + + const scopedVars = { __from: { value: 'a', text: 'b' } }; + + expect(sceneInterpolator(scene, '${__from}', scopedVars)).toBe('a'); + expect(sceneInterpolator(scene, '${__from:text}', scopedVars)).toBe('b'); + }); + + it('Can use scopedVars with fieldPath', () => { + const scene = new TestScene({ + $variables: new SceneVariableSet({ + variables: [], + }), + }); + + const scopedVars = { __data: { value: { name: 'Main org' }, text: '' } }; + expect(sceneInterpolator(scene, '${__data.name}', scopedVars)).toBe('Main org'); + }); +}); diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts new file mode 100644 index 00000000000..57345a856a5 --- /dev/null +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts @@ -0,0 +1,128 @@ +import { ScopedVars } from '@grafana/data'; +import { VariableModel } from '@grafana/schema'; +import { variableRegex } from 'app/features/variables/utils'; + +import { EmptyVariableSet, sceneGraph } from '../../core/sceneGraph'; +import { SceneObject } from '../../core/types'; +import { SceneVariable, VariableValue } from '../types'; + +import { getSceneVariableForScopedVar } from './ScopedVarsVariable'; +import { formatRegistry, FormatRegistryID } from './formatRegistry'; + +type CustomFormatterFn = ( + value: unknown, + legacyVariableModel: VariableModel, + legacyDefaultFormatter: CustomFormatterFn +) => string; + +/** + * This function will try to parse and replace any variable expression found in the target string. The sceneObject will be used as the source of variables. It will + * use the scene graph and walk up the parent tree until it finds the closest variable. + * + * ScopedVars should not really be needed much in the new scene architecture as they can be added to the local scene node instead of passed in interpolate function. + * It is supported here for backward compatibility and some edge cases where adding scoped vars to local scene node is not practical. + */ +export function sceneInterpolator( + sceneObject: SceneObject, + target: string | undefined | null, + scopedVars?: ScopedVars, + format?: string | CustomFormatterFn +): string { + if (!target) { + return target ?? ''; + } + + // Skip any interpolation if there are no variables in the scene object graph + if (sceneGraph.getVariables(sceneObject) === EmptyVariableSet) { + return target; + } + + variableRegex.lastIndex = 0; + + return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const variableName = var1 || var2 || var3; + const fmt = fmt2 || fmt3 || format; + let variable: SceneVariable | undefined | null; + + if (scopedVars && scopedVars[variableName]) { + variable = getSceneVariableForScopedVar(variableName, scopedVars[variableName]); + } else { + variable = lookupSceneVariable(variableName, sceneObject); + } + + if (!variable) { + return match; + } + + return formatValue(variable, variable.getValue(fieldPath), fmt); + }); +} + +function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { + const variables = sceneObject.state.$variables; + if (!variables) { + if (sceneObject.parent) { + return lookupSceneVariable(name, sceneObject.parent); + } else { + return null; + } + } + + const found = variables.getByName(name); + if (found) { + return found; + } else if (sceneObject.parent) { + return lookupSceneVariable(name, sceneObject.parent); + } + + return null; +} + +function formatValue( + variable: SceneVariable, + value: VariableValue | undefined | null, + formatNameOrFn: string | CustomFormatterFn +): string { + if (value === null || value === undefined) { + return ''; + } + + // if (isAdHoc(variable) && format !== FormatRegistryID.queryParam) { + // return ''; + // } + + // if it's an object transform value to string + if (!Array.isArray(value) && typeof value === 'object') { + value = `${value}`; + } + + if (typeof formatNameOrFn === 'function') { + // legacy custom formatter function, TODO + //return format(value, {}, this.formatValue); + throw new Error('Custom formatter function not supported'); + } + + let args: string[] = []; + + if (!formatNameOrFn) { + formatNameOrFn = FormatRegistryID.glob; + } else { + // some formats have arguments that come after ':' character + args = formatNameOrFn.split(':'); + if (args.length > 1) { + formatNameOrFn = args[0]; + args = args.slice(1); + } else { + args = []; + } + } + + let formatter = formatRegistry.getIfExists(formatNameOrFn); + + if (!formatter) { + console.error(`Variable format ${formatNameOrFn} not found. Using glob format as fallback.`); + formatter = formatRegistry.get(FormatRegistryID.glob); + } + + return formatter.formatter(value, args, variable); +} diff --git a/public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts b/public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts deleted file mode 100644 index 22214be1c5f..00000000000 --- a/public/app/features/scenes/variables/sceneTemplateInterpolator.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { SceneObjectBase } from '../core/SceneObjectBase'; -import { SceneObjectStatePlain } from '../core/types'; - -import { sceneTemplateInterpolator } from './sceneTemplateInterpolator'; -import { SceneVariableSet } from './sets/SceneVariableSet'; -import { ConstantVariable } from './variants/ConstantVariable'; -import { ObjectVariable } from './variants/ObjectVariable'; - -interface TestSceneState extends SceneObjectStatePlain { - nested?: TestScene; -} - -class TestScene extends SceneObjectBase {} - -describe('sceneTemplateInterpolator', () => { - it('Should be interpolate and use closest variable', () => { - const scene = new TestScene({ - $variables: new SceneVariableSet({ - variables: [ - new ConstantVariable({ - name: 'test', - value: 'hello', - }), - new ConstantVariable({ - name: 'atRootOnly', - value: 'RootValue', - }), - ], - }), - nested: new TestScene({ - $variables: new SceneVariableSet({ - variables: [ - new ConstantVariable({ - name: 'test', - value: 'nestedValue', - }), - ], - }), - }), - }); - - expect(sceneTemplateInterpolator('${test}', scene)).toBe('hello'); - expect(sceneTemplateInterpolator('${test}', scene.state.nested!)).toBe('nestedValue'); - expect(sceneTemplateInterpolator('${atRootOnly}', scene.state.nested!)).toBe('RootValue'); - }); - - describe('Given an expression with fieldPath', () => { - it('Should interpolate correctly', () => { - const scene = new TestScene({ - $variables: new SceneVariableSet({ - variables: [ - new ObjectVariable({ - name: 'test', - value: { prop1: 'prop1Value' }, - }), - ], - }), - }); - - expect(sceneTemplateInterpolator('${test.prop1}', scene)).toBe('prop1Value'); - }); - }); -}); diff --git a/public/app/features/scenes/variables/sceneTemplateInterpolator.ts b/public/app/features/scenes/variables/sceneTemplateInterpolator.ts deleted file mode 100644 index 716b95ed703..00000000000 --- a/public/app/features/scenes/variables/sceneTemplateInterpolator.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isArray } from 'lodash'; - -import { variableRegex } from 'app/features/variables/utils'; - -import { SceneObject } from '../core/types'; - -import { SceneVariable } from './types'; - -export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) { - // Skip any interpolation if there are no variables in the scene object graph - if (!sceneObject.getVariables()) { - return target; - } - - variableRegex.lastIndex = 0; - - return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { - const variableName = var1 || var2 || var3; - const variable = lookupSceneVariable(variableName, sceneObject); - - if (!variable) { - return match; - } - - const value = variable.getValue(fieldPath); - - if (isArray(value)) { - return 'not supported yet'; - } - - return String(value); - }); -} - -function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { - const variables = sceneObject.state.$variables; - if (!variables) { - if (sceneObject.parent) { - return lookupSceneVariable(name, sceneObject.parent); - } else { - return null; - } - } - - const found = variables.getByName(name); - if (found) { - return found; - } else if (sceneObject.parent) { - return lookupSceneVariable(name, sceneObject.parent); - } - - return null; -} diff --git a/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx b/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx index 98954ecabf2..c5898267f9b 100644 --- a/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx +++ b/public/app/features/scenes/variables/sets/SceneVariableSet.test.tsx @@ -72,7 +72,7 @@ describe('SceneVariableList', () => { C.signalUpdateCompleted(); // When changing A should start B but not C (yet) - A.onSingleValueChange({ value: 'AB', text: 'AB' }); + A.changeValueTo('AB'); expect(B.state.loading).toBe(true); expect(C.state.loading).toBe(false); @@ -125,7 +125,7 @@ describe('SceneVariableList', () => { expect((sceneObjectWithVariable as any)._renderCount).toBe(2); act(() => { - B.onSingleValueChange({ value: 'B', text: 'B' }); + B.changeValueTo('B'); }); expect(screen.getByText('AA - B')).toBeInTheDocument(); diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts index 5cb9dafa629..a2ac42835fe 100644 --- a/public/app/features/scenes/variables/types.ts +++ b/public/app/features/scenes/variables/types.ts @@ -24,7 +24,7 @@ export interface SceneVariable): void; } diff --git a/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts b/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts index fe7c5ddc457..2df3a4dcb09 100644 --- a/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts +++ b/public/app/features/scenes/variables/variants/MultiValueVariable.test.ts @@ -1,6 +1,8 @@ import { lastValueFrom, Observable, of } from 'rxjs'; -import { VariableValueOption } from '../types'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; + +import { SceneVariableValueChangedEvent, VariableValueOption } from '../types'; import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from '../variants/MultiValueVariable'; export interface ExampleVariableState extends MultiValueVariableState { @@ -47,5 +49,108 @@ describe('MultiValueVariable', () => { expect(variable.state.value).toBe('A'); expect(variable.state.text).toBe('A'); }); + + it('Should maintain the valid values when multiple selected', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + isMulti: true, + optionsToReturn: [ + { label: 'A', value: 'A' }, + { label: 'C', value: 'C' }, + ], + value: ['A', 'B', 'C'], + text: ['A', 'B', 'C'], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(['A', 'C']); + expect(variable.state.text).toEqual(['A', 'C']); + }); + + it('Should pick first option if none of the current values are valid', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + isMulti: true, + optionsToReturn: [ + { label: 'A', value: 'A' }, + { label: 'C', value: 'C' }, + ], + value: ['D', 'E'], + text: ['E', 'E'], + }); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toEqual(['A']); + expect(variable.state.text).toEqual(['A']); + }); + + it('Should handle $__all value and send change event even when value is still $__all', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + optionsToReturn: [ + { label: 'A', value: '1' }, + { label: 'B', value: '2' }, + ], + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + }); + + let changeEvent: SceneVariableValueChangedEvent | undefined; + variable.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => (changeEvent = evt)); + + await lastValueFrom(variable.validateAndUpdate()); + + expect(variable.state.value).toBe(ALL_VARIABLE_VALUE); + expect(variable.state.text).toBe(ALL_VARIABLE_TEXT); + expect(variable.state.options).toEqual(variable.state.optionsToReturn); + expect(changeEvent).toBeDefined(); + }); + }); + + describe('getValue and getValueText', () => { + it('GetValueText should return text', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + optionsToReturn: [], + value: '1', + text: 'A', + }); + + expect(variable.getValue()).toBe('1'); + expect(variable.getValueText()).toBe('A'); + }); + + it('GetValueText should return All text when value is $__all', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [], + optionsToReturn: [], + value: ALL_VARIABLE_VALUE, + text: 'A', + }); + + expect(variable.getValueText()).toBe(ALL_VARIABLE_TEXT); + }); + + it('GetValue should return all options as an array when value is $__all', async () => { + const variable = new ExampleVariable({ + name: 'test', + options: [ + { label: 'A', value: '1' }, + { label: 'B', value: '2' }, + ], + optionsToReturn: [], + value: ALL_VARIABLE_VALUE, + text: 'A', + }); + + expect(variable.getValue()).toEqual(['1', '2']); + }); }); }); diff --git a/public/app/features/scenes/variables/variants/MultiValueVariable.ts b/public/app/features/scenes/variables/variants/MultiValueVariable.ts index 54519f4959c..941865b9bf4 100644 --- a/public/app/features/scenes/variables/variants/MultiValueVariable.ts +++ b/public/app/features/scenes/variables/variants/MultiValueVariable.ts @@ -1,6 +1,7 @@ +import { isEqual } from 'lodash'; import { map, Observable } from 'rxjs'; -import { SelectableValue } from '@grafana/data'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneObject } from '../../core/types'; @@ -14,8 +15,8 @@ import { } from '../types'; export interface MultiValueVariableState extends SceneVariableState { - value: string | string[]; // old current.text - text: string | string[]; // old current.value + value: VariableValue; // old current.text + text: VariableValue; // old current.value options: VariableValueOption[]; isMulti?: boolean; } @@ -47,40 +48,92 @@ export abstract class MultiValueVariable = { + options, + loading: false, + value: this.state.value, + text: this.state.text, + }; + if (options.length === 0) { // TODO handle the no value state - this.setStateHelper({ value: '?', loading: false }); - return; + } else if (this.hasAllValue()) { + // If value is set to All then we keep it set to All but just store the options + } else if (this.state.isMulti) { + // If we are a multi valued variable validate the current values are among the options + const currentValues = Array.isArray(this.state.value) ? this.state.value : [this.state.value]; + const validValues = currentValues.filter((v) => options.find((o) => o.value === v)); + + // If no valid values pick the first option + if (validValues.length === 0) { + stateUpdate.value = [options[0].value]; + stateUpdate.text = [options[0].label]; + } + // We have valid values, if it's different from current valid values update current values + else if (!isEqual(validValues, this.state.value)) { + const validTexts = validValues.map((v) => options.find((o) => o.value === v)!.label); + stateUpdate.value = validValues; + stateUpdate.text = validTexts; + } + } else { + // Single valued variable + const foundCurrent = options.find((x) => x.value === this.state.value); + if (!foundCurrent) { + // Current value is not valid. Set to first of the available options + stateUpdate.value = options[0].value; + stateUpdate.text = options[0].label; + } } - const foundCurrent = options.find((x) => x.value === this.state.value); - if (!foundCurrent) { - // Current value is not valid. Set to first of the available options - this.changeValueAndPublishChangeEvent(options[0].value, options[0].label); - } else { - // current value is still ok - this.setStateHelper({ loading: false }); + // Remember current value and text + const { value: prevValue, text: prevText } = this.state; + + // Perform state change + this.setStateHelper(stateUpdate); + + // Publish value changed event only if value changed + if (stateUpdate.value !== prevValue || stateUpdate.text !== prevText || this.hasAllValue()) { + this.publishEvent(new SceneVariableValueChangedEvent(this), true); } } public getValue(): VariableValue { + if (this.hasAllValue()) { + return this.state.options.map((x) => x.value); + } + return this.state.value; } public getValueText(): string { + if (this.hasAllValue()) { + return ALL_VARIABLE_TEXT; + } + if (Array.isArray(this.state.text)) { return this.state.text.join(' + '); } - return this.state.text; + return String(this.state.text); } - private changeValueAndPublishChangeEvent(value: string | string[], text: string | string[]) { + private hasAllValue() { + const value = this.state.value; + return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); + } + + private setStateAndPublishValueChangedEvent(state: Partial) { + this.setStateHelper(state); + } + + /** + * Change the value and publish SceneVariableValueChangedEvent event + */ + public changeValueTo(value: VariableValue, text?: VariableValue) { if (value !== this.state.value || text !== this.state.text) { - this.setStateHelper({ value, text, loading: false }); + this.setStateAndPublishValueChangedEvent({ value, text, loading: false }); this.publishEvent(new SceneVariableValueChangedEvent(this), true); } } @@ -92,15 +145,4 @@ export abstract class MultiValueVariable = this; test.setState(state); } - - public onSingleValueChange = (value: SelectableValue) => { - this.changeValueAndPublishChangeEvent(value.value!, value.label!); - }; - - public onMultiValueChange = (value: Array>) => { - this.changeValueAndPublishChangeEvent( - value.map((v) => v.value!), - value.map((v) => v.label!) - ); - }; } diff --git a/public/app/features/scenes/variables/variants/TestVariable.tsx b/public/app/features/scenes/variables/variants/TestVariable.tsx index bca7cdbc719..a9ad24ec298 100644 --- a/public/app/features/scenes/variables/variants/TestVariable.tsx +++ b/public/app/features/scenes/variables/variants/TestVariable.tsx @@ -3,10 +3,10 @@ import { Observable, Subject } from 'rxjs'; import { queryMetricTree } from 'app/plugins/datasource/testdata/metricTree'; +import { sceneGraph } from '../../core/sceneGraph'; import { SceneComponentProps } from '../../core/types'; import { VariableDependencyConfig } from '../VariableDependencyConfig'; import { VariableValueSelect } from '../components/VariableValueSelect'; -import { sceneTemplateInterpolator } from '../sceneTemplateInterpolator'; import { VariableValueOption } from '../types'; import { MultiValueVariable, MultiValueVariableState, VariableGetOptionsArgs } from './MultiValueVariable'; @@ -28,6 +28,17 @@ export class TestVariable extends MultiValueVariable { statePaths: ['query'], }); + public constructor(initialState: Partial) { + super({ + name: 'Test', + value: 'Value', + text: 'Text', + query: 'Query', + options: [], + ...initialState, + }); + } + public getValueOptions(args: VariableGetOptionsArgs): Observable { const { delayMs } = this.state; @@ -56,7 +67,7 @@ export class TestVariable extends MultiValueVariable { } private issueQuery() { - const interpolatedQuery = sceneTemplateInterpolator(this.state.query, this); + const interpolatedQuery = sceneGraph.interpolate(this, this.state.query); const options = queryMetricTree(interpolatedQuery).map((x) => ({ label: x.name, value: x.name })); this.setState({ diff --git a/public/app/plugins/datasource/testdata/metricTree.test.ts b/public/app/plugins/datasource/testdata/metricTree.test.ts index 7491f167c05..b94f8b9965b 100644 --- a/public/app/plugins/datasource/testdata/metricTree.test.ts +++ b/public/app/plugins/datasource/testdata/metricTree.test.ts @@ -14,11 +14,11 @@ describe('MetricTree', () => { it('queryMetric tree supports glob paths', () => { const nodes = queryMetricTree('A.{AB,AC}.*').map((i) => i.name); - expect(nodes).toEqual(['ABA', 'ABB', 'ABC', 'ACA', 'ACB', 'ACC']); + expect(nodes).toEqual(expect.arrayContaining(['ABA', 'ABB', 'ABC', 'ACA', 'ACB', 'ACC'])); }); it('queryMetric tree supports wildcard matching', () => { const nodes = queryMetricTree('A.AB.AB*').map((i) => i.name); - expect(nodes).toEqual(['ABA', 'ABB', 'ABC']); + expect(nodes).toEqual(expect.arrayContaining(['ABA', 'ABB', 'ABC'])); }); }); diff --git a/public/app/plugins/datasource/testdata/metricTree.ts b/public/app/plugins/datasource/testdata/metricTree.ts index 6b617d4dd3f..109982d9c34 100644 --- a/public/app/plugins/datasource/testdata/metricTree.ts +++ b/public/app/plugins/datasource/testdata/metricTree.ts @@ -16,7 +16,7 @@ export interface TreeNode { * ] */ function buildMetricTree(parent: string, depth: number): TreeNode[] { - const chars = ['A', 'B', 'C']; + const chars = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; const children: TreeNode[] = []; if (depth > 5) { From 4ee83a5f2bf4e157a183c364ea0dbd06198b230e Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Wed, 16 Nov 2022 11:49:34 +0100 Subject: [PATCH 254/926] AppRootPage: Render app plugins without pages (#58776) fix: render app plugins that don't have a page in includes --- public/app/features/plugins/routes.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/public/app/features/plugins/routes.tsx b/public/app/features/plugins/routes.tsx index c05c0afe7da..6e26112cb68 100644 --- a/public/app/features/plugins/routes.tsx +++ b/public/app/features/plugins/routes.tsx @@ -12,8 +12,7 @@ export function getAppPluginRoutes(): RouteDescriptor[] { const isStandalonePluginPage = (id: string) => id.startsWith('standalone-plugin-page-/'); const isPluginNavModelItem = (model: NavModelItem): model is PluginNavModelItem => 'pluginId' in model && 'id' in model; - - return Object.values(navIndex) + const explicitAppPluginRoutes = Object.values(navIndex) .filter(isPluginNavModelItem) .map((navItem) => { const pluginNavSection = getRootSectionForNode(navItem); @@ -26,6 +25,17 @@ export function getAppPluginRoutes(): RouteDescriptor[] { component: () => , }; }); + + return [ + ...explicitAppPluginRoutes, + + // Fallback route for plugins that don't have any pages under includes + { + path: '/a/:pluginId', + exact: false, // route everything under this path to the plugin, so it can define more routes under this path + component: ({ match }) => , + }, + ]; } interface PluginNavModelItem extends Omit { From 3f63ca06c3625a1c78a0d52abefbe0026f2f7486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 16 Nov 2022 13:02:29 +0100 Subject: [PATCH 255/926] Internationalization: Translate NavBar - 'Search dashboard' menu item (#58815) --- public/app/core/components/NavBar/navBarItem-translations.ts | 2 ++ public/locales/de-DE/grafana.json | 3 +++ public/locales/en-US/grafana.json | 3 +++ public/locales/es-ES/grafana.json | 3 +++ public/locales/fr-FR/grafana.json | 3 +++ public/locales/pseudo-LOCALE/grafana.json | 3 +++ public/locales/zh-Hans/grafana.json | 3 +++ 7 files changed, 20 insertions(+) diff --git a/public/app/core/components/NavBar/navBarItem-translations.ts b/public/app/core/components/NavBar/navBarItem-translations.ts index 489420ec3a1..db6a018ebb4 100644 --- a/public/app/core/components/NavBar/navBarItem-translations.ts +++ b/public/app/core/components/NavBar/navBarItem-translations.ts @@ -121,6 +121,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.profile/password.title', 'Change password'); case 'sign-out': return t('nav.sign-out.title', 'Sign out'); + case 'search': + return t('nav.search-dashboards.title', 'Search dashboards'); default: return undefined; } diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index f8d014b4763..27d9c51e8b2 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -253,6 +253,9 @@ "search": { "placeholder": "Grafana durchsuchen" }, + "search-dashboards": { + "title": "" + }, "server-settings": { "subtitle": "Zeige die in deiner Grafana-Konfiguration festgelegten Einstellungen an", "title": "Einstellungen" diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a2948102dec..513c32a7268 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -253,6 +253,9 @@ "search": { "placeholder": "Search Grafana" }, + "search-dashboards": { + "title": "Search dashboards" + }, "server-settings": { "subtitle": "View the settings defined in your Grafana config", "title": "Settings" diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 279a0268ddb..7e4eda723da 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -253,6 +253,9 @@ "search": { "placeholder": "Buscar Grafana" }, + "search-dashboards": { + "title": "" + }, "server-settings": { "subtitle": "Vea la configuración definida en los ajustes de Grafana", "title": "Configuración" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 293a6870412..3267d649676 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -253,6 +253,9 @@ "search": { "placeholder": "Rechercher dans Grafana" }, + "search-dashboards": { + "title": "" + }, "server-settings": { "subtitle": "Afficher les paramètres définis dans votre configuration Grafana", "title": "Paramètres" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 11149157a5c..29e0f16a0d7 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -253,6 +253,9 @@ "search": { "placeholder": "Ŝęäřčĥ Ğřäƒäʼnä" }, + "search-dashboards": { + "title": "Ŝęäřčĥ đäşĥþőäřđş" + }, "server-settings": { "subtitle": "Vįęŵ ŧĥę şęŧŧįʼnģş đęƒįʼnęđ įʼn yőūř Ğřäƒäʼnä čőʼnƒįģ", "title": "Ŝęŧŧįʼnģş" diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 8b6769c214d..6698e77b582 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -253,6 +253,9 @@ "search": { "placeholder": "搜索 Grafana" }, + "search-dashboards": { + "title": "" + }, "server-settings": { "subtitle": "查看 Grafana 配置中定义的设置", "title": "设置" From 515440979bf945b4d982c5b7a9294e89fb5a4ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 16 Nov 2022 13:04:51 +0100 Subject: [PATCH 256/926] Internationalization: Translate ShareSnapshot label (#58802) --- .../features/dashboard/components/ShareModal/ShareSnapshot.tsx | 2 +- public/locales/de-DE/grafana.json | 3 ++- public/locales/en-US/grafana.json | 3 ++- public/locales/es-ES/grafana.json | 3 ++- public/locales/fr-FR/grafana.json | 3 ++- public/locales/pseudo-LOCALE/grafana.json | 3 ++- public/locales/zh-Hans/grafana.json | 3 ++- 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx index dc6eb43372d..a2282f748ce 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx @@ -278,7 +278,7 @@ export class ShareSnapshot extends PureComponent { return ( <> - + Date: Wed, 16 Nov 2022 13:06:04 +0100 Subject: [PATCH 257/926] Internationalization: Translate 'Hide / show legend' of PanelHeaderMenuItem (#58800) --- public/app/features/dashboard/utils/getPanelMenu.ts | 4 +++- public/locales/de-DE/grafana.json | 2 ++ public/locales/en-US/grafana.json | 2 ++ public/locales/es-ES/grafana.json | 2 ++ public/locales/fr-FR/grafana.json | 2 ++ public/locales/pseudo-LOCALE/grafana.json | 2 ++ public/locales/zh-Hans/grafana.json | 2 ++ 7 files changed, 15 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 152423751a8..2be759fc7b5 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -227,7 +227,9 @@ export function getPanelMenu( if (panel.options.legend) { subMenu.push({ - text: panel.options.legend.showLegend ? 'Hide legend' : 'Show legend', + text: panel.options.legend.showLegend + ? t('panel.header-menu.hide-legend', 'Hide legend') + : t('panel.header-menu.show-legend', 'Show legend'), onClick: onToggleLegend, shortcut: 'p l', }); diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 126c634c0a5..940eb77aa34 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -304,11 +304,13 @@ }, "panel": { "header-menu": { + "hide-legend": "", "inspect": "Überprüfen", "inspect-data": "Daten", "inspect-json": "Panel-JSON", "more": "Mehr …", "share": "Teilen", + "show-legend": "", "view": "Anzeigen" } }, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 434f5a03416..c3d7bab249e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -304,11 +304,13 @@ }, "panel": { "header-menu": { + "hide-legend": "Hide legend", "inspect": "Inspect", "inspect-data": "Data", "inspect-json": "Panel JSON", "more": "More...", "share": "Share", + "show-legend": "Show legend", "view": "View" } }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index bb5d4cd8fe5..b6020ca1118 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -304,11 +304,13 @@ }, "panel": { "header-menu": { + "hide-legend": "", "inspect": "Inspeccionar", "inspect-data": "Datos", "inspect-json": "JSON de panel", "more": "Más...", "share": "Compartir", + "show-legend": "", "view": "Vista" } }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index d371ed44c7d..50ee9d5efa8 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -304,11 +304,13 @@ }, "panel": { "header-menu": { + "hide-legend": "", "inspect": "Inspecter", "inspect-data": "Données", "inspect-json": "Panneau JSON", "more": "Plus...", "share": "Partager", + "show-legend": "", "view": "Afficher" } }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 0a8db2e7a8a..cef669c3987 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -304,11 +304,13 @@ }, "panel": { "header-menu": { + "hide-legend": "Ħįđę ľęģęʼnđ", "inspect": "Ĩʼnşpęčŧ", "inspect-data": "Đäŧä", "inspect-json": "Päʼnęľ ĴŜØŃ", "more": "Mőřę...", "share": "Ŝĥäřę", + "show-legend": "Ŝĥőŵ ľęģęʼnđ", "view": "Vįęŵ" } }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 8d39937c106..c1623c71043 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -304,11 +304,13 @@ }, "panel": { "header-menu": { + "hide-legend": "", "inspect": "检查", "inspect-data": "数据", "inspect-json": "面板 JSON", "more": "更多...", "share": "分享", + "show-legend": "", "view": "查看" } }, From 74010fd05d0e58f6bdc060f893059d455028490d Mon Sep 17 00:00:00 2001 From: matt abrams <37156449+zuchka@users.noreply.github.com> Date: Wed, 16 Nov 2022 14:17:39 +0100 Subject: [PATCH 258/926] Admin: Fix broken links to image assets in email templates (#58729) fix broken links to image assets --- docs/sources/old-alerting/notifications.md | 2 +- emails/templates/layouts/default.html | 2 +- pkg/services/alerting/notifiers/discord.go | 2 +- pkg/services/alerting/notifiers/hipchat.go | 2 +- pkg/services/alerting/notifiers/slack.go | 2 +- pkg/services/alerting/test_notification.go | 2 +- .../ngalert/notifier/channels/discord.go | 2 +- .../ngalert/notifier/channels/discord_test.go | 32 +++++++++---------- .../ngalert/notifier/channels/slack_test.go | 10 +++--- .../ngalert/notifier/channels/util.go | 2 +- .../alerting/api_notification_channel_test.go | 6 ++-- public/emails/alert_notification.html | 2 +- public/emails/alert_notification_example.html | 2 +- public/emails/invited_to_org.html | 2 +- public/emails/new_user_invite.html | 2 +- public/emails/ng_alert_notification.html | 2 +- public/emails/reset_password.html | 2 +- public/emails/signup_started.html | 2 +- public/emails/welcome_on_signup.html | 2 +- 19 files changed, 40 insertions(+), 40 deletions(-) diff --git a/docs/sources/old-alerting/notifications.md b/docs/sources/old-alerting/notifications.md index d5011165990..60c02db0775 100644 --- a/docs/sources/old-alerting/notifications.md +++ b/docs/sources/old-alerting/notifications.md @@ -194,7 +194,7 @@ Example json body: "tags": {} } ], - "imageUrl": "https://grafana.com/assets/img/blog/mixed_styles.png", + "imageUrl": "https://grafana.com/static/assets/img/blog/mixed_styles.png", "message": "Notification Message", "orgId": 1, "panelId": 2, diff --git a/emails/templates/layouts/default.html b/emails/templates/layouts/default.html index 1723e20e8a8..7e580ac910f 100644 --- a/emails/templates/layouts/default.html +++ b/emails/templates/layouts/default.html @@ -107,7 +107,7 @@ td[class="stack-column-center"] { diff --git a/pkg/services/alerting/notifiers/discord.go b/pkg/services/alerting/notifiers/discord.go index a2647eef841..f90d1e12e06 100644 --- a/pkg/services/alerting/notifiers/discord.go +++ b/pkg/services/alerting/notifiers/discord.go @@ -124,7 +124,7 @@ func (dn *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error { footer := map[string]interface{}{ "text": "Grafana v" + setting.BuildVersion, - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", } color, _ := strconv.ParseInt(strings.TrimLeft(evalContext.GetStateModel().Color, "#"), 16, 0) diff --git a/pkg/services/alerting/notifiers/hipchat.go b/pkg/services/alerting/notifiers/hipchat.go index 78a141cea7e..c625c96bdd5 100644 --- a/pkg/services/alerting/notifiers/hipchat.go +++ b/pkg/services/alerting/notifiers/hipchat.go @@ -148,7 +148,7 @@ func (hc *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error { "title": evalContext.GetNotificationTitle(), "description": message, "icon": map[string]interface{}{ - "url": "https://grafana.com/assets/img/fav32.png", + "url": "https://grafana.com/static/assets/img/fav32.png", }, "date": evalContext.EndTime.Unix(), "attributes": attributes, diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index b88f89e8204..86f1b7a4f59 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -284,7 +284,7 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error { "fallback": evalContext.GetNotificationTitle(), "fields": fields, "footer": "Grafana v" + setting.BuildVersion, - "footer_icon": "https://grafana.com/assets/img/fav32.png", + "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "ts": time.Now().Unix(), } if sn.NeedsImage() && imageURL != "" { diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index 0119fc167fc..2759a8a681c 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -60,7 +60,7 @@ func createTestEvalContext(cmd *NotificationTestCommand) *EvalContext { ctx := NewEvalContext(context.Background(), testRule, fakeRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo()) if cmd.Settings.Get("uploadImage").MustBool(true) { - ctx.ImagePublicURL = "https://grafana.com/assets/img/blog/mixed_styles.png" + ctx.ImagePublicURL = "https://grafana.com/static/assets/img/blog/mixed_styles.png" } ctx.IsTestRun = true ctx.Firing = true diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go index fc471d165aa..e074337815c 100644 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ b/pkg/services/ngalert/notifier/channels/discord.go @@ -121,7 +121,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, footer := map[string]interface{}{ "text": "Grafana v" + setting.BuildVersion, - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", } linkEmbed := simplejson.New() diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index e6a7fc3a681..8a0bbb72f6d 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -46,7 +46,7 @@ func TestDiscordNotifier(t *testing.T) { "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:1] (val1)", @@ -73,7 +73,7 @@ func TestDiscordNotifier(t *testing.T) { "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "Alerts firing: 1", @@ -87,7 +87,7 @@ func TestDiscordNotifier(t *testing.T) { { name: "Missing field in template", settings: `{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "url": "http://localhost", "message": "I'm a custom template {{ .NotAField }} bad template" }`, @@ -100,12 +100,12 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "content": "I'm a custom template ", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:1] (val1)", @@ -119,7 +119,7 @@ func TestDiscordNotifier(t *testing.T) { { name: "Invalid message template", settings: `{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "url": "http://localhost", "message": "{{ template \"invalid.template\" }}" }`, @@ -132,12 +132,12 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "content": "", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:1] (val1)", @@ -169,7 +169,7 @@ func TestDiscordNotifier(t *testing.T) { "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:1] (val1)", @@ -183,7 +183,7 @@ func TestDiscordNotifier(t *testing.T) { { name: "Invalid URL template", settings: `{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "url": "http://localhost?q={{invalid }}}", "message": "valid message" }`, @@ -196,12 +196,12 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "content": "valid message", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:1] (val1)", @@ -215,7 +215,7 @@ func TestDiscordNotifier(t *testing.T) { { name: "Custom config with multiple alerts", settings: `{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "url": "http://localhost", "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" }`, @@ -233,12 +233,12 @@ func TestDiscordNotifier(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "avatar_url": "https://grafana.com/assets/img/fav32.png", + "avatar_url": "https://grafana.com/static/assets/img/fav32.png", "content": "2 alerts are firing, 0 are resolved", "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:2] ", @@ -273,7 +273,7 @@ func TestDiscordNotifier(t *testing.T) { "embeds": []interface{}{map[string]interface{}{ "color": 1.4037554e+07, "footer": map[string]interface{}{ - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + setting.BuildVersion, }, "title": "[FIRING:1] (val1)", diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index e57a3e95e72..5c450f9f248 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -74,7 +74,7 @@ func TestSlackNotifier(t *testing.T) { Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/assets/img/fav32.png", + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", Color: "#D63232", Ts: 0, }, @@ -109,7 +109,7 @@ func TestSlackNotifier(t *testing.T) { Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/assets/img/fav32.png", + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", Color: "#D63232", Ts: 0, }, @@ -144,7 +144,7 @@ func TestSlackNotifier(t *testing.T) { Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/assets/img/fav32.png", + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", Color: "#D63232", Ts: 0, ImageURL: "https://www.example.com/image.jpg", @@ -187,7 +187,7 @@ func TestSlackNotifier(t *testing.T) { Fallback: "2 firing, 0 resolved", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/assets/img/fav32.png", + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", Color: "#D63232", Ts: 0, }, @@ -235,7 +235,7 @@ func TestSlackNotifier(t *testing.T) { Fallback: "[FIRING:1] (val1)", Fields: nil, Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: "https://grafana.com/assets/img/fav32.png", + FooterIcon: "https://grafana.com/static/assets/img/fav32.png", Color: "#D63232", Ts: 0, }, diff --git a/pkg/services/ngalert/notifier/channels/util.go b/pkg/services/ngalert/notifier/channels/util.go index 3d5bcd62e8e..409fa98901a 100644 --- a/pkg/services/ngalert/notifier/channels/util.go +++ b/pkg/services/ngalert/notifier/channels/util.go @@ -30,7 +30,7 @@ import ( ) const ( - FooterIconURL = "https://grafana.com/assets/img/fav32.png" + FooterIconURL = "https://grafana.com/static/assets/img/fav32.png" ColorAlertFiring = "#D63232" ColorAlertResolved = "#36a64f" diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 35b2477d6b8..c4c557faca3 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2322,7 +2322,7 @@ var expNonEmailNotifications = map[string][]string{ "text": "Integration Test ", "fallback": "Integration Test [FIRING:1] SlackAlert1 (default)", "footer": "Grafana v", - "footer_icon": "https://grafana.com/assets/img/fav32.png", + "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, "mrkdwn_in": ["pretext"], @@ -2342,7 +2342,7 @@ var expNonEmailNotifications = map[string][]string{ "text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%%3DSlackAlert2&matcher=grafana_folder%%3Ddefault\n", "fallback": "[FIRING:1] SlackAlert2 (default)", "footer": "Grafana v", - "footer_icon": "https://grafana.com/assets/img/fav32.png", + "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, "mrkdwn_in": ["pretext"], @@ -2480,7 +2480,7 @@ var expNonEmailNotifications = map[string][]string{ { "color": 14037554, "footer": { - "icon_url": "https://grafana.com/assets/img/fav32.png", + "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" }, "title": "[FIRING:1] DiscordAlert (default)", diff --git a/public/emails/alert_notification.html b/public/emails/alert_notification.html index 5e6cdc4551e..15e1c36dd43 100644 --- a/public/emails/alert_notification.html +++ b/public/emails/alert_notification.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/alert_notification_example.html b/public/emails/alert_notification_example.html index 481715e471b..34c0c34ab05 100644 --- a/public/emails/alert_notification_example.html +++ b/public/emails/alert_notification_example.html @@ -191,7 +191,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/invited_to_org.html b/public/emails/invited_to_org.html index 88c672199eb..7a7a76924d6 100644 --- a/public/emails/invited_to_org.html +++ b/public/emails/invited_to_org.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/new_user_invite.html b/public/emails/new_user_invite.html index 58eacb1eaab..0728cefffdc 100644 --- a/public/emails/new_user_invite.html +++ b/public/emails/new_user_invite.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/ng_alert_notification.html b/public/emails/ng_alert_notification.html index cfdfe91aeab..c0824fac335 100644 --- a/public/emails/ng_alert_notification.html +++ b/public/emails/ng_alert_notification.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/reset_password.html b/public/emails/reset_password.html index f4ad5672eaa..ab4ef1c761c 100644 --- a/public/emails/reset_password.html +++ b/public/emails/reset_password.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/signup_started.html b/public/emails/signup_started.html index e5a0be8ac0d..15f1945862d 100644 --- a/public/emails/signup_started.html +++ b/public/emails/signup_started.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    diff --git a/public/emails/welcome_on_signup.html b/public/emails/welcome_on_signup.html index 52cc9a58e7d..28ac926fb06 100644 --- a/public/emails/welcome_on_signup.html +++ b/public/emails/welcome_on_signup.html @@ -183,7 +183,7 @@ text-decoration: underline;
    - +
    From 20133ec6fbc0a31c462269e16f588fcc59e209fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 16 Nov 2022 14:55:31 +0100 Subject: [PATCH 259/926] Variables: Use new format registry from templateSrv (#58813) * Variables: Use new format registry from templateSrv * Updated comment * Fixed e2e --- .betterer.results | 7 - .../dashboard-templating.spec.ts | 2 +- .../interpolation/ScopedVarsVariable.ts | 23 +- .../variables/interpolation/formatRegistry.ts | 30 +- .../interpolation/sceneInterpolator.ts | 10 +- public/app/features/scenes/variables/types.ts | 2 +- .../templating/LegacyVariableWrapper.ts | 53 ++++ .../templating/formatRegistry.test.ts | 75 ----- .../app/features/templating/formatRegistry.ts | 287 ------------------ .../features/templating/template_srv.test.ts | 3 +- .../app/features/templating/template_srv.ts | 13 +- .../datasource/postgres/PostgresQueryModel.ts | 2 +- 12 files changed, 102 insertions(+), 405 deletions(-) create mode 100644 public/app/features/templating/LegacyVariableWrapper.ts delete mode 100644 public/app/features/templating/formatRegistry.test.ts delete mode 100644 public/app/features/templating/formatRegistry.ts diff --git a/.betterer.results b/.betterer.results index bfb01521da9..9b47eddaeb4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4708,13 +4708,6 @@ exports[`better eslint`] = { "public/app/features/teams/state/selectors.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/templating/formatRegistry.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "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"] - ], "public/app/features/templating/template_srv.mock.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/e2e/dashboards-suite/dashboard-templating.spec.ts b/e2e/dashboards-suite/dashboard-templating.spec.ts index 913a8d181d7..f555d05fecc 100644 --- a/e2e/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e/dashboards-suite/dashboard-templating.spec.ts @@ -33,7 +33,7 @@ e2e.scenario({ `Server:singlequote = 'A\\'A"A','BB\\B','CCC'`, `Server:doublequote = "A'A\\"A","BB\\B","CCC"`, `Server:sqlstring = 'A''A"A','BB\\\B','CCC'`, - `Server:date = null`, + `Server:date = NaN`, `Server:text = All`, `Server:queryparam = var-Server=All`, `1 < 2`, diff --git a/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts index ff555e5a9bf..1b724265d7a 100644 --- a/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts +++ b/public/app/features/scenes/variables/interpolation/ScopedVarsVariable.ts @@ -2,19 +2,19 @@ import { property } from 'lodash'; import { ScopedVar } from '@grafana/data'; -import { SceneObjectBase } from '../../core/SceneObjectBase'; -import { SceneVariable, SceneVariableState, VariableValue } from '../types'; +import { VariableValue } from '../types'; -export interface ScopedVarsProxyVariableState extends SceneVariableState { - value: ScopedVar; -} +import { FormatVariable } from './formatRegistry'; -export class ScopedVarsVariable - extends SceneObjectBase - implements SceneVariable -{ +export class ScopedVarsVariable implements FormatVariable { private static fieldAccessorCache: FieldAccessorCache = {}; + public state: { name: string; value: ScopedVar }; + + public constructor(name: string, value: ScopedVar) { + this.state = { name, value }; + } + public getValue(fieldPath: string): VariableValue { let { value } = this.state; let realValue = value.value; @@ -63,9 +63,10 @@ let scopedVarsVariable: ScopedVarsVariable | undefined; */ export function getSceneVariableForScopedVar(name: string, value: ScopedVar) { if (!scopedVarsVariable) { - scopedVarsVariable = new ScopedVarsVariable({ name, value }); + scopedVarsVariable = new ScopedVarsVariable(name, value); } else { - scopedVarsVariable.setState({ name, value }); + scopedVarsVariable.state.name = name; + scopedVarsVariable.state.value = value; } return scopedVarsVariable; diff --git a/public/app/features/scenes/variables/interpolation/formatRegistry.ts b/public/app/features/scenes/variables/interpolation/formatRegistry.ts index e216ee58418..3d4e3fe6148 100644 --- a/public/app/features/scenes/variables/interpolation/formatRegistry.ts +++ b/public/app/features/scenes/variables/interpolation/formatRegistry.ts @@ -4,10 +4,24 @@ import { dateTime, Registry, RegistryItem, textUtil } from '@grafana/data'; import kbn from 'app/core/utils/kbn'; import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; -import { SceneVariable, VariableValue, VariableValueSingle } from '../types'; +import { VariableValue, VariableValueSingle } from '../types'; export interface FormatRegistryItem extends RegistryItem { - formatter(value: VariableValue, args: string[], variable: SceneVariable): string; + formatter(value: VariableValue, args: string[], variable: FormatVariable): string; +} + +/** + * Slimmed down version of the SceneVariable interface so that it only contains what the formatters actually use. + * This is useful as we have some implementations of this interface that does not need to be full scene objects. + * For example ScopedVarsVariable and LegacyVariableWrapper. + */ +export interface FormatVariable { + state: { + name: string; + }; + + getValue(fieldPath?: string): VariableValue | undefined | null; + getValueText?(fieldPath?: string): string; } export enum FormatRegistryID { @@ -231,14 +245,16 @@ export const formatRegistry = new Registry(() => { name: 'Date', description: 'Format date in different ways', formatter: (value, args) => { - let nrValue = 0; + let nrValue = NaN; if (typeof value === 'number') { nrValue = value; } else if (typeof value === 'string') { nrValue = parseInt(value, 10); - } else { - return ''; + } + + if (isNaN(nrValue)) { + return 'NaN'; } const arg = args[0] ?? 'iso'; @@ -270,10 +286,6 @@ export const formatRegistry = new Registry(() => { name: 'Text', description: 'Format variables in their text representation. Example in multi-variable scenario A + B + C.', formatter: (value, _args, variable) => { - // if (typeof options.text === 'string') { - // return options.value === ALL_VARIABLE_VALUE ? ALL_VARIABLE_TEXT : options.text; - // } - if (variable.getValueText) { return variable.getValueText(); } diff --git a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts index 57345a856a5..06466d7d892 100644 --- a/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts +++ b/public/app/features/scenes/variables/interpolation/sceneInterpolator.ts @@ -4,10 +4,10 @@ import { variableRegex } from 'app/features/variables/utils'; import { EmptyVariableSet, sceneGraph } from '../../core/sceneGraph'; import { SceneObject } from '../../core/types'; -import { SceneVariable, VariableValue } from '../types'; +import { VariableValue } from '../types'; import { getSceneVariableForScopedVar } from './ScopedVarsVariable'; -import { formatRegistry, FormatRegistryID } from './formatRegistry'; +import { formatRegistry, FormatRegistryID, FormatVariable } from './formatRegistry'; type CustomFormatterFn = ( value: unknown, @@ -42,7 +42,7 @@ export function sceneInterpolator( return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { const variableName = var1 || var2 || var3; const fmt = fmt2 || fmt3 || format; - let variable: SceneVariable | undefined | null; + let variable: FormatVariable | undefined | null; if (scopedVars && scopedVars[variableName]) { variable = getSceneVariableForScopedVar(variableName, scopedVars[variableName]); @@ -58,7 +58,7 @@ export function sceneInterpolator( }); } -function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { +function lookupSceneVariable(name: string, sceneObject: SceneObject): FormatVariable | null | undefined { const variables = sceneObject.state.$variables; if (!variables) { if (sceneObject.parent) { @@ -79,7 +79,7 @@ function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVaria } function formatValue( - variable: SceneVariable, + variable: FormatVariable, value: VariableValue | undefined | null, formatNameOrFn: string | CustomFormatterFn ): string { diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts index a2ac42835fe..d845bffcb85 100644 --- a/public/app/features/scenes/variables/types.ts +++ b/public/app/features/scenes/variables/types.ts @@ -31,7 +31,7 @@ export interface SceneVariable { - describe('with lucene formatter', () => { - const { formatter } = formatRegistry.get(FormatRegistryID.lucene); - - it('should escape single value', () => { - expect( - formatter( - { - value: 'foo bar', - text: '', - args: [], - }, - dummyVar - ) - ).toBe('foo\\ bar'); - }); - - it('should not escape negative number', () => { - expect( - formatter( - { - value: '-1', - text: '', - args: [], - }, - dummyVar - ) - ).toBe('-1'); - }); - - it('should escape string prepended with dash', () => { - expect( - formatter( - { - value: '-test', - text: '', - args: [], - }, - dummyVar - ) - ).toBe('\\-test'); - }); - - it('should escape multi value', () => { - expect( - formatter( - { - value: ['foo bar', 'baz'], - text: '', - args: [], - }, - dummyVar - ) - ).toBe('("foo\\ bar" OR "baz")'); - }); - - it('should escape empty value', () => { - expect( - formatter( - { - value: [], - text: '', - args: [], - }, - dummyVar - ) - ).toBe('__empty__'); - }); - }); -}); diff --git a/public/app/features/templating/formatRegistry.ts b/public/app/features/templating/formatRegistry.ts deleted file mode 100644 index f90039ec879..00000000000 --- a/public/app/features/templating/formatRegistry.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { isArray, map, replace } from 'lodash'; - -import { dateTime, Registry, RegistryItem, textUtil, TypedVariableModel } from '@grafana/data'; -import kbn from 'app/core/utils/kbn'; - -import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants'; -import { formatVariableLabel } from '../variables/shared/formatVariable'; - -export interface FormatOptions { - value: any; - text: string; - args: string[]; -} - -export interface FormatRegistryItem extends RegistryItem { - formatter(options: FormatOptions, variable: TypedVariableModel): string; -} - -export enum FormatRegistryID { - lucene = 'lucene', - raw = 'raw', - regex = 'regex', - pipe = 'pipe', - distributed = 'distributed', - csv = 'csv', - html = 'html', - json = 'json', - percentEncode = 'percentencode', - singleQuote = 'singlequote', - doubleQuote = 'doublequote', - sqlString = 'sqlstring', - date = 'date', - glob = 'glob', - text = 'text', - queryParam = 'queryparam', -} - -export const formatRegistry = new Registry(() => { - const formats: FormatRegistryItem[] = [ - { - id: FormatRegistryID.lucene, - name: 'Lucene', - description: 'Values are lucene escaped and multi-valued variables generate an OR expression', - formatter: ({ value }) => { - if (typeof value === 'string') { - return luceneEscape(value); - } - - if (value instanceof Array && value.length === 0) { - return '__empty__'; - } - - const quotedValues = map(value, (val: string) => { - return '"' + luceneEscape(val) + '"'; - }); - - return '(' + quotedValues.join(' OR ') + ')'; - }, - }, - { - id: FormatRegistryID.raw, - name: 'raw', - description: 'Keep value as is', - formatter: ({ value }) => value, - }, - { - id: FormatRegistryID.regex, - name: 'Regex', - description: 'Values are regex escaped and multi-valued variables generate a (|) expression', - formatter: ({ value }) => { - if (typeof value === 'string') { - return kbn.regexEscape(value); - } - - const escapedValues = map(value, kbn.regexEscape); - if (escapedValues.length === 1) { - return escapedValues[0]; - } - return '(' + escapedValues.join('|') + ')'; - }, - }, - { - id: FormatRegistryID.pipe, - name: 'Pipe', - description: 'Values are separated by | character', - formatter: ({ value }) => { - if (typeof value === 'string') { - return value; - } - return value.join('|'); - }, - }, - { - id: FormatRegistryID.distributed, - name: 'Distributed', - description: 'Multiple values are formatted like variable=value', - formatter: ({ value }, variable) => { - if (typeof value === 'string') { - return value; - } - - value = map(value, (val: any, index: number) => { - if (index !== 0) { - return variable.name + '=' + val; - } else { - return val; - } - }); - return value.join(','); - }, - }, - { - id: FormatRegistryID.csv, - name: 'Csv', - description: 'Comma-separated values', - formatter: ({ value }) => { - if (isArray(value)) { - return value.join(','); - } - return value; - }, - }, - { - id: FormatRegistryID.html, - name: 'HTML', - description: 'HTML escaping of values', - formatter: ({ value }) => { - if (isArray(value)) { - return textUtil.escapeHtml(value.join(', ')); - } - return textUtil.escapeHtml(value); - }, - }, - { - id: FormatRegistryID.json, - name: 'JSON', - description: 'JSON stringify valu', - formatter: ({ value }) => { - return JSON.stringify(value); - }, - }, - { - id: FormatRegistryID.percentEncode, - name: 'Percent encode', - description: 'Useful for URL escaping values', - formatter: ({ value }) => { - // like glob, but url escaped - if (isArray(value)) { - return encodeURIComponentStrict('{' + value.join(',') + '}'); - } - return encodeURIComponentStrict(value); - }, - }, - { - id: FormatRegistryID.singleQuote, - name: 'Single quote', - description: 'Single quoted values', - formatter: ({ value }) => { - // escape single quotes with backslash - const regExp = new RegExp(`'`, 'g'); - if (isArray(value)) { - return map(value, (v: string) => `'${replace(v, regExp, `\\'`)}'`).join(','); - } - return `'${replace(value, regExp, `\\'`)}'`; - }, - }, - { - id: FormatRegistryID.doubleQuote, - name: 'Double quote', - description: 'Double quoted values', - formatter: ({ value }) => { - // escape double quotes with backslash - const regExp = new RegExp('"', 'g'); - if (isArray(value)) { - return map(value, (v: string) => `"${replace(v, regExp, '\\"')}"`).join(','); - } - return `"${replace(value, regExp, '\\"')}"`; - }, - }, - { - id: FormatRegistryID.sqlString, - name: 'SQL string', - description: 'SQL string quoting and commas for use in IN statements and other scenarios', - formatter: ({ value }) => { - // escape single quotes by pairing them - const regExp = new RegExp(`'`, 'g'); - if (isArray(value)) { - return map(value, (v) => `'${replace(v, regExp, "''")}'`).join(','); - } - return `'${replace(value, regExp, "''")}'`; - }, - }, - { - id: FormatRegistryID.date, - name: 'Date', - description: 'Format date in different ways', - formatter: ({ value, args }) => { - const arg = args[0] ?? 'iso'; - - switch (arg) { - case 'ms': - return value; - case 'seconds': - return `${Math.round(parseInt(value, 10)! / 1000)}`; - case 'iso': - return dateTime(parseInt(value, 10)).toISOString(); - default: - return dateTime(parseInt(value, 10)).format(arg); - } - }, - }, - { - id: FormatRegistryID.glob, - name: 'Glob', - description: 'Format multi-valued variables using glob syntax, example {value1,value2}', - formatter: ({ value }) => { - if (isArray(value) && value.length > 1) { - return '{' + value.join(',') + '}'; - } - return value; - }, - }, - { - id: FormatRegistryID.text, - name: 'Text', - description: 'Format variables in their text representation. Example in multi-variable scenario A + B + C.', - formatter: (options, variable) => { - if (typeof options.text === 'string') { - return options.value === ALL_VARIABLE_VALUE ? ALL_VARIABLE_TEXT : options.text; - } - - const current = (variable as any)?.current; - - if (!current) { - return options.value; - } - - return formatVariableLabel(variable); - }, - }, - { - id: FormatRegistryID.queryParam, - name: 'Query parameter', - description: - 'Format variables as URL parameters. Example in multi-variable scenario A + B + C => var-foo=A&var-foo=B&var-foo=C.', - formatter: (options, variable) => { - const { value } = options; - const { name } = variable; - - if (Array.isArray(value)) { - return value.map((v) => formatQueryParameter(name, v)).join('&'); - } - - return formatQueryParameter(name, value); - }, - }, - ]; - - return formats; -}); - -function luceneEscape(value: string) { - if (isNaN(+value) === false) { - return value; - } - - return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1'); -} - -/** - * encode string according to RFC 3986; in contrast to encodeURIComponent() - * also the sub-delims "!", "'", "(", ")" and "*" are encoded; - * unicode handling uses UTF-8 as in ECMA-262. - */ -function encodeURIComponentStrict(str: string) { - return encodeURIComponent(str).replace(/[!'()*]/g, (c) => { - return '%' + c.charCodeAt(0).toString(16).toUpperCase(); - }); -} - -function formatQueryParameter(name: string, value: string): string { - return `var-${name}=${encodeURIComponentStrict(value)}`; -} - -export function isAllValue(value: any) { - return value === ALL_VARIABLE_VALUE || (Array.isArray(value) && value[0] === ALL_VARIABLE_VALUE); -} diff --git a/public/app/features/templating/template_srv.test.ts b/public/app/features/templating/template_srv.test.ts index 25e9528ab35..d0f18300f7d 100644 --- a/public/app/features/templating/template_srv.test.ts +++ b/public/app/features/templating/template_srv.test.ts @@ -4,13 +4,12 @@ import { setDataSourceSrv } from '@grafana/runtime'; import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; import { initTemplateSrv } from '../../../test/helpers/initTemplateSrv'; import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks'; +import { FormatRegistryID } from '../scenes/variables/interpolation/formatRegistry'; import { VariableAdapter, variableAdapters } from '../variables/adapters'; import { createAdHocVariableAdapter } from '../variables/adhoc/adapter'; import { createQueryVariableAdapter } from '../variables/query/adapter'; import { VariableModel } from '../variables/types'; -import { FormatRegistryID } from './formatRegistry'; - const key = 'key'; variableAdapters.setInit(() => [ diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index b282b9d9593..2ed9730600a 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -10,13 +10,14 @@ import { } from '@grafana/data'; import { getDataSourceSrv, setTemplateSrv, TemplateSrv as BaseTemplateSrv } from '@grafana/runtime'; +import { formatRegistry, FormatRegistryID } from '../scenes/variables/interpolation/formatRegistry'; import { variableAdapters } from '../variables/adapters'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../variables/constants'; import { isAdHoc } from '../variables/guard'; import { getFilteredVariables, getVariables, getVariableWithName } from '../variables/state/selectors'; import { variableRegex } from '../variables/utils'; -import { FormatOptions, formatRegistry, FormatRegistryID } from './formatRegistry'; +import { getVariableWrapper } from './LegacyVariableWrapper'; interface FieldAccessorCache { [key: string]: (obj: any) => any; @@ -38,7 +39,7 @@ export class TemplateSrv implements BaseTemplateSrv { private _variables: any[]; private regex = variableRegex; private index: any = {}; - private grafanaVariables: any = {}; + private grafanaVariables = new Map(); private timeRange?: TimeRange | null = null; private fieldAccessorCache: FieldAccessorCache = {}; @@ -165,12 +166,12 @@ export class TemplateSrv implements BaseTemplateSrv { formatItem = formatRegistry.get(FormatRegistryID.glob); } - const options: FormatOptions = { value, args, text: text ?? value }; - return formatItem.formatter(options, variable); + const formatVariable = getVariableWrapper(variable.name, value, text ?? value); + return formatItem.formatter(value, args, formatVariable); } setGrafanaVariable(name: string, value: any) { - this.grafanaVariables[name] = value; + this.grafanaVariables.set(name, value); } /** @@ -306,7 +307,7 @@ export class TemplateSrv implements BaseTemplateSrv { return this.formatValue(value, fmt, variable, text); } - const systemValue = this.grafanaVariables[variable.current.value]; + const systemValue = this.grafanaVariables.get(variable.current.value); if (systemValue) { return this.formatValue(systemValue, fmt, variable); } diff --git a/public/app/plugins/datasource/postgres/PostgresQueryModel.ts b/public/app/plugins/datasource/postgres/PostgresQueryModel.ts index 90e28e27b1c..eaf5262e54a 100644 --- a/public/app/plugins/datasource/postgres/PostgresQueryModel.ts +++ b/public/app/plugins/datasource/postgres/PostgresQueryModel.ts @@ -2,7 +2,7 @@ import { ScopedVars } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; import { applyQueryDefaults } from 'app/features/plugins/sql/defaults'; import { SQLQuery, SqlQueryModel } from 'app/features/plugins/sql/types'; -import { FormatRegistryID } from 'app/features/templating/formatRegistry'; +import { FormatRegistryID } from 'app/features/scenes/variables/interpolation/formatRegistry'; export class PostgresQueryModel implements SqlQueryModel { target: SQLQuery; From 5d73f7f8e8561bf40fc28c6077c5d006af05b473 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Wed, 16 Nov 2022 16:07:53 +0200 Subject: [PATCH 260/926] CI: Cleanup `e2e` tests dependencies (#58829) Cleanup end to end tests dependencies --- scripts/drone/steps/lib.star | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 14d09dd5fa7..6e80c0e9708 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -274,7 +274,7 @@ def store_storybook_step(edition, ver_mode, trigger=None): step = { 'name': 'store-storybook', 'image': publish_image, - 'depends_on': ['build-storybook', ] + end_to_end_tests_deps(edition), + 'depends_on': ['build-storybook', ] + end_to_end_tests_deps(), 'environment': { 'GCP_KEY': from_secret('gcp_key'), 'PRERELEASE_BUCKET': from_secret(prerelease_bucket) @@ -956,7 +956,7 @@ def release_canary_npm_packages_step(edition, trigger=None): step = { 'name': 'release-canary-npm-packages', 'image': build_image, - 'depends_on': end_to_end_tests_deps(edition), + 'depends_on': end_to_end_tests_deps(), 'environment': { 'NPM_TOKEN': from_secret('npm_token'), }, @@ -980,12 +980,12 @@ def upload_packages_step(edition, ver_mode, trigger=None): return None deps = [] - if edition in 'enterprise2' or not end_to_end_tests_deps(edition): + if edition in 'enterprise2' or not end_to_end_tests_deps(): deps.extend([ 'package' + enterprise2_suffix(edition), ]) else: - deps.extend(end_to_end_tests_deps(edition)) + deps.extend(end_to_end_tests_deps()) step = { 'name': 'upload-packages' + enterprise2_suffix(edition), @@ -1250,14 +1250,14 @@ def artifacts_page_step(): ], } -def end_to_end_tests_deps(edition): +def end_to_end_tests_deps(): if disable_tests: return [] return [ - 'end-to-end-tests-dashboards-suite' + enterprise2_suffix(edition), - 'end-to-end-tests-panels-suite' + enterprise2_suffix(edition), - 'end-to-end-tests-smoke-tests-suite' + enterprise2_suffix(edition), - 'end-to-end-tests-various-suite' + enterprise2_suffix(edition), + 'end-to-end-tests-dashboards-suite', + 'end-to-end-tests-panels-suite', + 'end-to-end-tests-smoke-tests-suite', + 'end-to-end-tests-various-suite', ] def compile_build_cmd(edition='oss'): From 332630c2e0c132d2abdc1d95d3f787a3d7ac1221 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Wed, 16 Nov 2022 09:21:11 -0500 Subject: [PATCH 261/926] kindsys: Make kind generators run in Windows (#58794) --- kinds/gen.go | 5 ++--- pkg/kindsys/load.go | 15 ++++----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/kinds/gen.go b/kinds/gen.go index 588fa614631..9b58b05e79e 100644 --- a/kinds/gen.go +++ b/kinds/gen.go @@ -12,10 +12,10 @@ import ( "os" "path/filepath" "sort" - "strings" "cuelang.org/go/cue/errors" "github.com/grafana/codejen" + "github.com/grafana/grafana/pkg/codegen" "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/grafana/pkg/kindsys" @@ -57,8 +57,7 @@ func main() { fmt.Fprintf(os.Stderr, "could not get working directory: %s", err) os.Exit(1) } - grootp := strings.Split(cwd, sep) - groot := filepath.Join(sep, filepath.Join(grootp[:len(grootp)-1]...)) + groot := filepath.Dir(cwd) rt := cuectx.GrafanaThemaRuntime() var all []*codegen.DeclForGen diff --git a/pkg/kindsys/load.go b/pkg/kindsys/load.go index 08e4b857435..29448f9052b 100644 --- a/pkg/kindsys/load.go +++ b/pkg/kindsys/load.go @@ -8,10 +8,11 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/errors" - "github.com/grafana/grafana" - "github.com/grafana/grafana/pkg/cuectx" "github.com/grafana/thema" tload "github.com/grafana/thema/load" + + "github.com/grafana/grafana" + "github.com/grafana/grafana/pkg/cuectx" ) // CoreStructuredDeclParentPath is the path, relative to the repository root, where @@ -54,15 +55,7 @@ func doLoadFrameworkCUE(ctx *cue.Context) (cue.Value, error) { var v cue.Value var err error - absolutePath := prefix - if !filepath.IsAbs(absolutePath) { - absolutePath, err = filepath.Abs(absolutePath) - if err != nil { - return v, err - } - } - - bi, err := tload.InstancesWithThema(grafana.CueSchemaFS, absolutePath) + bi, err := tload.InstancesWithThema(grafana.CueSchemaFS, prefix) if err != nil { return v, err } From bce83485a9b9d0ae229d87b2520959f981f24c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 16 Nov 2022 15:47:44 +0100 Subject: [PATCH 262/926] Scenes: Share factory function for test data query runner (#58816) --- public/app/features/scenes/scenes/grid.tsx | 16 +----- .../scenes/scenes/gridMultiTimeRange.tsx | 43 ++------------ .../features/scenes/scenes/gridMultiple.tsx | 16 +----- .../scenes/scenes/gridWithMultipleData.tsx | 57 ++----------------- .../features/scenes/scenes/gridWithRow.tsx | 16 +----- .../features/scenes/scenes/gridWithRows.tsx | 16 +----- public/app/features/scenes/scenes/nested.tsx | 29 ++-------- 7 files changed, 27 insertions(+), 166 deletions(-) diff --git a/public/app/features/scenes/scenes/grid.tsx b/public/app/features/scenes/scenes/grid.tsx index cefa6b61b9e..4cf629fade4 100644 --- a/public/app/features/scenes/scenes/grid.tsx +++ b/public/app/features/scenes/scenes/grid.tsx @@ -7,7 +7,8 @@ import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridLayoutTest(): Scene { const scene = new Scene({ @@ -57,18 +58,7 @@ export function getGridLayoutTest(): Scene { }), $editor: new SceneEditManager({}), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx index f58854a99ce..cb730ce1dbb 100644 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -6,7 +6,8 @@ import { VizPanel } from '../components/VizPanel'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridWithMultipleTimeRanges(): Scene { const globalTimeRange = new SceneTimeRange(getDefaultTimeRange()); @@ -24,18 +25,7 @@ export function getGridWithMultipleTimeRanges(): Scene { children: [ new SceneGridRow({ $timeRange: row1TimeRange, - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk_table', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: 'random_walk_table' }), title: 'Row A - has its own query, last year time range', key: 'Row A', isCollapsed: true, @@ -61,19 +51,7 @@ export function getGridWithMultipleTimeRanges(): Scene { }), new VizPanel({ - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - seriesCount: 10, - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), isResizable: true, isDraggable: true, pluginId: 'timeseries', @@ -90,18 +68,7 @@ export function getGridWithMultipleTimeRanges(): Scene { }), $editor: new SceneEditManager({}), $timeRange: globalTimeRange, - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridMultiple.tsx b/public/app/features/scenes/scenes/gridMultiple.tsx index bb4044c9e76..087081e576b 100644 --- a/public/app/features/scenes/scenes/gridMultiple.tsx +++ b/public/app/features/scenes/scenes/gridMultiple.tsx @@ -7,7 +7,8 @@ import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getMultipleGridLayoutTest(): Scene { const scene = new Scene({ @@ -101,18 +102,7 @@ export function getMultipleGridLayoutTest(): Scene { $editor: new SceneEditManager({}), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx index d492f81b057..f4752793079 100644 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -6,7 +6,8 @@ import { VizPanel } from '../components/VizPanel'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridWithMultipleData(): Scene { const scene = new Scene({ @@ -15,18 +16,7 @@ export function getGridWithMultipleData(): Scene { children: [ new SceneGridRow({ $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk_table', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: 'random_walk_table' }), title: 'Row A - has its own query', key: 'Row A', isCollapsed: true, @@ -65,19 +55,7 @@ export function getGridWithMultipleData(): Scene { size: { x: 0, y: 2, width: 12, height: 5 }, }), new VizPanel({ - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - seriesCount: 10, - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery({ seriesCount: 10 }), pluginId: 'timeseries', title: 'Row B Child2 with data', key: 'Row B Child2', @@ -88,19 +66,7 @@ export function getGridWithMultipleData(): Scene { ], }), new VizPanel({ - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - seriesCount: 10, - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery({ seriesCount: 10 }), isResizable: true, isDraggable: true, pluginId: 'timeseries', @@ -130,18 +96,7 @@ export function getGridWithMultipleData(): Scene { }), $editor: new SceneEditManager({}), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridWithRow.tsx b/public/app/features/scenes/scenes/gridWithRow.tsx index cbda038b18c..38dadce9cf1 100644 --- a/public/app/features/scenes/scenes/gridWithRow.tsx +++ b/public/app/features/scenes/scenes/gridWithRow.tsx @@ -6,7 +6,8 @@ import { VizPanel } from '../components/VizPanel'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridWithRowLayoutTest(): Scene { const scene = new Scene({ @@ -78,18 +79,7 @@ export function getGridWithRowLayoutTest(): Scene { }), $editor: new SceneEditManager({}), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridWithRows.tsx b/public/app/features/scenes/scenes/gridWithRows.tsx index 609373dd256..1f47a22e708 100644 --- a/public/app/features/scenes/scenes/gridWithRows.tsx +++ b/public/app/features/scenes/scenes/gridWithRows.tsx @@ -7,7 +7,8 @@ import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridWithRowsTest(): Scene { const panel = new VizPanel({ @@ -83,18 +84,7 @@ export function getGridWithRowsTest(): Scene { }), $editor: new SceneEditManager({}), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx index 4b99f9bb497..cd4e60889fa 100644 --- a/public/app/features/scenes/scenes/nested.tsx +++ b/public/app/features/scenes/scenes/nested.tsx @@ -6,7 +6,8 @@ import { SceneTimePicker } from '../components/SceneTimePicker'; import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getNestedScene(): Scene { const scene = new Scene({ @@ -23,18 +24,7 @@ export function getNestedScene(): Scene { ], }), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); @@ -57,18 +47,7 @@ export function getInnerScene(title: string) { ], }), $timeRange: new SceneTimeRange(getDefaultTimeRange()), - $data: new SceneQueryRunner({ - queries: [ - { - refId: 'A', - datasource: { - uid: 'gdev-testdata', - type: 'testdata', - }, - scenarioId: 'random_walk', - }, - ], - }), + $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); From a8c48b6801b63750781b2bf151c5f587d0305c7e Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 16 Nov 2022 15:54:04 +0100 Subject: [PATCH 263/926] RBAC: Cover plugin includes (#57582) * RBAC: Add action to plugin includes * Adding the feature toggle check * Cue update * Extract include access control to method * Suggestion to prevent log when RBAC is disabled Co-authored-by: ievaVasiljeva * Rename IsRBACReady to RequireRBACAction Co-authored-by: ievaVasiljeva --- pkg/plugins/models.go | 5 +++++ pkg/plugins/plugindef/plugindef.cue | 5 ++++- pkg/plugins/plugindef/plugindef_types_gen.go | 5 ++++- pkg/services/navtree/navtreeimpl/applinks.go | 20 +++++++++++++++++++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 674156de1ad..3eed57d64ca 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -87,6 +87,7 @@ type Includes struct { Type string `json:"type"` Component string `json:"component"` Role org.RoleType `json:"role"` + Action string `json:"action,omitempty"` AddToNav bool `json:"addToNav"` DefaultNav bool `json:"defaultNav"` Slug string `json:"slug"` @@ -103,6 +104,10 @@ func (e Includes) DashboardURLPath() string { return "/d/" + e.UID } +func (e Includes) RequiresRBACAction() bool { + return e.Action != "" +} + type Dependency struct { ID string `json:"id"` Type string `json:"type"` diff --git a/pkg/plugins/plugindef/plugindef.cue b/pkg/plugins/plugindef/plugindef.cue index 555f78531c8..b2b3df50e3e 100644 --- a/pkg/plugins/plugindef/plugindef.cue +++ b/pkg/plugins/plugindef/plugindef.cue @@ -88,6 +88,9 @@ seqs: [ component?: string role?: "Admin" | "Editor" | "Viewer" + // RBAC action the user must have to access the route + action?: string + // Used for app plugins. path?: string @@ -163,7 +166,7 @@ seqs: [ permissions: [...#Permission] } - // Permission describes an RBAC permission on the plugin. A permission has an action and an option + // Permission describes an RBAC permission on the plugin. A permission has an action and an optional // scope. // Example: action: 'test-app.schedules:read', scope: 'test-app.schedules:*' #Permission: { diff --git a/pkg/plugins/plugindef/plugindef_types_gen.go b/pkg/plugins/plugindef/plugindef_types_gen.go index b6532b774ea..39f2d1c9b17 100644 --- a/pkg/plugins/plugindef/plugindef_types_gen.go +++ b/pkg/plugins/plugindef/plugindef_types_gen.go @@ -362,6 +362,9 @@ type Header struct { // A resource to be included in a plugin. type Include struct { + // RBAC action the user must have to access the route + Action *string `json:"action,omitempty"` + // Add the include to the side menu. AddToNav *bool `json:"addToNav,omitempty"` @@ -462,7 +465,7 @@ type JWTTokenAuth struct { Url string `json:"url"` } -// Permission describes an RBAC permission on the plugin. A permission has an action and an option +// Permission describes an RBAC permission on the plugin. A permission has an action and an optional // scope. // Example: action: 'test-app.schedules:read', scope: 'test-app.schedules:*' type Permission struct { diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 0366fdd6dc7..9ac62c7cfd4 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -65,6 +65,7 @@ func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqCo } func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink { + hasAccessToInclude := s.hasAccessToInclude(c, plugin.ID) appLink := &navtree.NavLink{ Text: plugin.Name, Id: "plugin-page-" + plugin.ID, @@ -82,7 +83,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo } for _, include := range plugin.Includes { - if !c.HasUserRole(include.Role) { + if !hasAccessToInclude(include) { continue } @@ -230,6 +231,23 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo return nil } +func (s *ServiceImpl) hasAccessToInclude(c *models.ReqContext, pluginID string) func(include *plugins.Includes) bool { + hasAccess := ac.HasAccess(s.accessControl, c) + return func(include *plugins.Includes) bool { + useRBAC := s.features.IsEnabled(featuremgmt.FlagAccessControlOnCall) && + !s.accessControl.IsDisabled() && include.RequiresRBACAction() + if useRBAC && !hasAccess(ac.ReqHasRole(include.Role), ac.EvalPermission(include.Action)) { + s.log.Debug("plugin include is covered by RBAC, user doesn't have access", + "plugin", pluginID, + "include", include.Name) + return false + } else if !useRBAC && !c.HasUserRole(include.Role) { + return false + } + return true + } +} + func (s *ServiceImpl) readNavigationSettings() { s.navigationAppConfig = map[string]NavigationAppConfig{ "grafana-k8s-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 1, Text: "Kubernetes"}, From 9283773c12f91bbd0ed5e817d137938ce75861cb Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Wed, 16 Nov 2022 15:55:10 +0100 Subject: [PATCH 264/926] Teams: Support paginating and filtering more then 1000 teams (#58761) * TeamList: break out rows to its own component * TeamsState: Add total count * TeamList: Remove teamsCount prop * TeamList: Restructure code and use count from backend response * TeamList: calculate total pages using totalCount * TeamList: Rename to state to currentPage and the reducer to setCurrentPage * TeamList: remove wrapper functions * TeamList: rewrite as a functional component * TeamList: export components for test * TeamList: pass limit, page and query to backend * TeamList: Rename properties in state and create actions for page and query change * TeamList: Add flag to control if EmptyList banner should render --- public/app/core/reducers/root.test.ts | 16 +- public/app/features/teams/TeamList.test.tsx | 19 +- public/app/features/teams/TeamList.tsx | 318 +++++++----------- public/app/features/teams/TeamListRow.tsx | 65 ++++ public/app/features/teams/state/actions.ts | 57 +++- .../app/features/teams/state/reducers.test.ts | 14 +- public/app/features/teams/state/reducers.ts | 34 +- .../features/teams/state/selectors.test.ts | 26 +- public/app/features/teams/state/selectors.ts | 13 +- public/app/types/teams.ts | 7 +- 10 files changed, 296 insertions(+), 273 deletions(-) create mode 100644 public/app/features/teams/TeamListRow.tsx diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index 86d694af84e..fd64756ac7b 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -28,12 +28,15 @@ describe('rootReducer', () => { reducerTester() .givenReducer(rootReducer, state) - .whenActionIsDispatched(teamsLoaded(teams)) + .whenActionIsDispatched(teamsLoaded({ teams: teams, page: 1, noTeams: false, perPage: 30, totalCount: 1 })) .thenStatePredicateShouldEqual((resultingState) => { expect(resultingState.teams).toEqual({ hasFetched: true, - searchQuery: '', - searchPage: 1, + noTeams: false, + perPage: 30, + totalPages: 1, + query: '', + page: 1, teams, }); return true; @@ -47,8 +50,11 @@ describe('rootReducer', () => { const state: StoreState = { teams: { hasFetched: true, - searchQuery: '', - searchPage: 1, + query: '', + page: 1, + noTeams: false, + totalPages: 1, + perPage: 30, teams, }, } as StoreState; diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 09a144b5998..c368f2ae02f 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -1,7 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { contextSrv, User } from 'app/core/services/context_srv'; @@ -9,7 +8,6 @@ import { OrgRole, Team } from '../../types'; import { Props, TeamList } from './TeamList'; import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; -import { setSearchQuery, setTeamsSearchPage } from './state/reducers'; jest.mock('app/core/config', () => ({ ...jest.requireActual('app/core/config'), @@ -19,13 +17,14 @@ jest.mock('app/core/config', () => ({ const setup = (propOverrides?: object) => { const props: Props = { teams: [] as Team[], + noTeams: false, loadTeams: jest.fn(), deleteTeam: jest.fn(), - setSearchQuery: mockToolkitActionCreator(setSearchQuery), - setTeamsSearchPage: mockToolkitActionCreator(setTeamsSearchPage), - searchQuery: '', - searchPage: 1, - teamsCount: 0, + changePage: jest.fn(), + changeQuery: jest.fn(), + query: '', + page: 1, + totalPages: 0, hasFetched: false, editorsCanAdmin: false, signedInUser: { @@ -52,7 +51,7 @@ describe('TeamList', () => { it('should enable the new team button', () => { setup({ teams: getMultipleMockTeams(1), - teamsCount: 1, + totalCount: 1, hasFetched: true, editorsCanAdmin: true, signedInUser: { @@ -69,7 +68,7 @@ describe('TeamList', () => { it('should disable the new team button', () => { setup({ teams: getMultipleMockTeams(1), - teamsCount: 1, + totalCount: 1, hasFetched: true, editorsCanAdmin: true, signedInUser: { @@ -87,7 +86,7 @@ describe('TeamList', () => { it('should call delete team', async () => { const mockDelete = jest.fn(); const mockTeam = getMockTeam(); - setup({ deleteTeam: mockDelete, teams: [mockTeam], teamsCount: 1, hasFetched: true }); + setup({ deleteTeam: mockDelete, teams: [mockTeam], totalCount: 1, hasFetched: true }); await userEvent.click(screen.getByRole('button', { name: `Delete team ${mockTeam.name}` })); await userEvent.click(screen.getByRole('button', { name: 'Delete' })); await waitFor(() => { diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index b5528a19812..1896aab4b2d 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -1,9 +1,8 @@ -import React, { PureComponent } from 'react'; +import React, { useEffect, useState } from 'react'; -import { DeleteButton, LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui'; +import { LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { Page } from 'app/core/components/Page/Page'; -import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; import { config } from 'app/core/config'; import { contextSrv, User } from 'app/core/services/context_srv'; @@ -11,22 +10,22 @@ import { AccessControlAction, Role, StoreState, Team } from 'app/types'; import { connectWithCleanUp } from '../../core/components/connectWithCleanUp'; -import { deleteTeam, loadTeams } from './state/actions'; -import { initialTeamsState, setSearchQuery, setTeamsSearchPage } from './state/reducers'; -import { getSearchQuery, getTeams, getTeamsCount, getTeamsSearchPage, isPermissionTeamAdmin } from './state/selectors'; - -const pageLimit = 30; +import { TeamListRow } from './TeamListRow'; +import { deleteTeam, loadTeams, changePage, changeQuery } from './state/actions'; +import { initialTeamsState } from './state/reducers'; +import { isPermissionTeamAdmin } from './state/selectors'; export interface Props { teams: Team[]; - searchQuery: string; - searchPage: number; - teamsCount: number; + page: number; + query: string; + noTeams: boolean; + totalPages: number; hasFetched: boolean; loadTeams: typeof loadTeams; deleteTeam: typeof deleteTeam; - setSearchQuery: typeof setSearchQuery; - setTeamsSearchPage: typeof setTeamsSearchPage; + changePage: typeof changePage; + changeQuery: typeof changeQuery; editorsCanAdmin: boolean; signedInUser: User; } @@ -35,199 +34,130 @@ export interface State { roleOptions: Role[]; } -export class TeamList extends PureComponent { - constructor(props: Props) { - super(props); - this.state = { roleOptions: [] }; - } +export const TeamList = ({ + teams, + page, + query, + noTeams, + totalPages, + hasFetched, + loadTeams, + deleteTeam, + changeQuery, + changePage, + signedInUser, + editorsCanAdmin, +}: Props) => { + const [roleOptions, setRoleOptions] = useState([]); - componentDidMount() { - this.fetchTeams(); + useEffect(() => { + loadTeams(true); + }, [loadTeams]); + + useEffect(() => { if (contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { - this.fetchRoleOptions(); + fetchRoleOptions().then((roles) => setRoleOptions(roles)); } - } + }, []); - async fetchTeams() { - await this.props.loadTeams(); - } + const canCreate = canCreateTeam(editorsCanAdmin); + const displayRolePicker = shouldDisplayRolePicker(); - async fetchRoleOptions() { - const roleOptions = await fetchRoleOptions(); - this.setState({ roleOptions }); - } - - deleteTeam = (team: Team) => { - this.props.deleteTeam(team.id); - }; - - onSearchQueryChange = (value: string) => { - this.props.setSearchQuery(value); - }; - - renderTeam(team: Team) { - const { editorsCanAdmin, signedInUser } = this.props; - const permission = team.permission; - const teamUrl = `org/teams/edit/${team.id}`; - const isTeamAdmin = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser }); - const canDelete = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsDelete, team, isTeamAdmin); - const canReadTeam = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRead, team, isTeamAdmin); - const canSeeTeamRoles = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRolesList, team, false); - const displayRolePicker = - contextSrv.licensedAccessControlEnabled() && contextSrv.hasPermission(AccessControlAction.ActionRolesList); - - return ( - - - - - - {displayRolePicker && ( - - )} - - - ); - } + ) : ( + <> +
    +
    + +
    - renderEmptyList() { - return ( - - ); - } + + New Team + +
    - getPaginatedTeams = (teams: Team[]) => { - const offset = (this.props.searchPage - 1) * pageLimit; - return teams.slice(offset, offset + pageLimit); - }; +
    + +
    - +
    - {canReadTeam ? ( - - Team avatar - - ) : ( - Team avatar - )} - - {canReadTeam ? {team.name} :
    {team.name}
    } -
    - {canReadTeam ? ( - 0 ? undefined : 'Empty email cell'}> - {team.email} - - ) : ( -
    0 ? undefined : 'Empty email cell'}> - {team.email} -
    - )} -
    - {canReadTeam ? ( - {team.memberCount} - ) : ( -
    {team.memberCount}
    - )} -
    {canSeeTeamRoles && } - this.deleteTeam(team)} + return ( + + + {noTeams ? ( + -
    + + + + + + {displayRolePicker && } + + + + {teams.map((team) => ( + + ))} + +
    + NameEmailMembersRoles +
    + + + + +
    + + )} + + + ); +}; - renderTeamList() { - const { teams, searchQuery, editorsCanAdmin, searchPage, setTeamsSearchPage } = this.props; - const teamAdmin = contextSrv.hasRole('Admin') || (editorsCanAdmin && contextSrv.hasRole('Editor')); - const canCreate = contextSrv.hasAccess(AccessControlAction.ActionTeamsCreate, teamAdmin); - const displayRolePicker = - contextSrv.licensedAccessControlEnabled() && - contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && - contextSrv.hasPermission(AccessControlAction.ActionRolesList); - const newTeamHref = canCreate ? 'org/teams/new' : '#'; - const paginatedTeams = this.getPaginatedTeams(teams); - const totalPages = Math.ceil(teams.length / pageLimit); +function canCreateTeam(editorsCanAdmin: boolean): boolean { + const teamAdmin = contextSrv.hasRole('Admin') || (editorsCanAdmin && contextSrv.hasRole('Editor')); + return contextSrv.hasAccess(AccessControlAction.ActionTeamsCreate, teamAdmin); +} - return ( - <> -
    -
    - -
    - - - New Team - -
    - -
    - - - - - - - - {displayRolePicker && } - - - {paginatedTeams.map((team) => this.renderTeam(team))} -
    - NameEmailMembersRoles -
    - - - -
    -
    - - ); - } - - renderList() { - const { teamsCount, hasFetched } = this.props; - - if (!hasFetched) { - return null; - } - - if (teamsCount > 0) { - return this.renderTeamList(); - } else { - return this.renderEmptyList(); - } - } - - render() { - const { hasFetched } = this.props; - - return ( - - {this.renderList()} - - ); - } +function shouldDisplayRolePicker(): boolean { + return ( + contextSrv.licensedAccessControlEnabled() && + contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesList) && + contextSrv.hasPermission(AccessControlAction.ActionRolesList) + ); } function mapStateToProps(state: StoreState) { return { - teams: getTeams(state.teams), - searchQuery: getSearchQuery(state.teams), - searchPage: getTeamsSearchPage(state.teams), - teamsCount: getTeamsCount(state.teams), + teams: state.teams.teams, + page: state.teams.page, + query: state.teams.query, + perPage: state.teams.perPage, + noTeams: state.teams.noTeams, + totalPages: state.teams.totalPages, hasFetched: state.teams.hasFetched, editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests, signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests, @@ -237,8 +167,8 @@ function mapStateToProps(state: StoreState) { const mapDispatchToProps = { loadTeams, deleteTeam, - setSearchQuery, - setTeamsSearchPage, + changePage, + changeQuery, }; export default connectWithCleanUp( diff --git a/public/app/features/teams/TeamListRow.tsx b/public/app/features/teams/TeamListRow.tsx new file mode 100644 index 00000000000..ad7fc7f6c9e --- /dev/null +++ b/public/app/features/teams/TeamListRow.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { DeleteButton } from '@grafana/ui'; +import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction, Role, Team } from 'app/types'; + +type Props = { + team: Team; + roleOptions: Role[]; + isTeamAdmin: boolean; + displayRolePicker: boolean; + onDelete: (id: number) => void; +}; + +export const TeamListRow = ({ team, roleOptions, isTeamAdmin, displayRolePicker, onDelete }: Props) => { + const teamUrl = `org/teams/edit/${team.id}`; + const canDelete = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsDelete, team, isTeamAdmin); + const canReadTeam = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRead, team, isTeamAdmin); + const canSeeTeamRoles = contextSrv.hasAccessInMetadata(AccessControlAction.ActionTeamsRolesList, team, false); + + return ( +
    + {canReadTeam ? ( + + Team avatar + + ) : ( + Team avatar + )} + + {canReadTeam ? {team.name} :
    {team.name}
    } +
    + {canReadTeam ? ( + 0 ? undefined : 'Empty email cell'}> + {team.email} + + ) : ( +
    0 ? undefined : 'Empty email cell'}> + {team.email} +
    + )} +
    + {canReadTeam ? ( + {team.memberCount} + ) : ( +
    {team.memberCount}
    + )} +
    {canSeeTeamRoles && } + onDelete(team.id)} + /> +
    - - - -
    - - - - -
    -

    [[.Title]]

    -
    -
    - - - - - -
    - - - - -
    -

    [[.Message]]

    -
    -
    - -[[if ne .Error "" ]] - - - - -
    -
    - - - - - - - -
    -
    Error message
    -
    -

    [[.Error]]

    -
    -
    -
    -[[end]] - -[[if ne .State "ok" ]] - - - - -
    -
    - - - - - - [[range .EvalMatches]] - - - - - [[end]] -
    -
    Metric name
    -
    -
    Value
    -
    -
    [[.Metric]]
    -
    -
    [[.Value]]
    -
    -
    -
    -[[end]] - - - - - -
    - - - - -
    - [[if ne .ImageLink "" ]] - Alerting Panel - [[end]] - [[if ne .EmbeddedImage "" ]] - Alerting Panel - [[end]] -
    -
    - - - - - - -
    - - - - - -
    - - - - -
    - View your Alert rule -
    -
    - - - - -
    - Go to the Alerts page -
    -
    -
    - - diff --git a/emails/templates/alert_notification.txt b/emails/templates/alert_notification.txt deleted file mode 100644 index 92b8d91386e..00000000000 --- a/emails/templates/alert_notification.txt +++ /dev/null @@ -1,26 +0,0 @@ -[[Subject .Subject "[[.Title]]"]] - -[[.Title]] ----------------- - -[[.Message]] - -[[if ne .Error "" ]] -Error message: -[[.Error]] -[[end]] - -[[if ne .State "ok" ]] -[[range .EvalMatches]] -Metric name: -[[.Metric]] -Value: -[[.Value]] -[[end]] -[[end]] - -View your Alert rule: -[[.RuleUrl]]" - -Go to the Alerts page: -[[.AlertPageUrl]] diff --git a/emails/templates/invited_to_org.html b/emails/templates/invited_to_org.html deleted file mode 100644 index 69ef61587df..00000000000 --- a/emails/templates/invited_to_org.html +++ /dev/null @@ -1,47 +0,0 @@ - - -[[Subject .Subject "[[.InvitedBy]] has added you to the [[.OrgName]] organization"]] - - - - - -
    - - - - - - -
    -

    You have been added to [[.OrgName]]

    -
    - -
    - - - - - -
    - - - - - - - - -
    -

    [[.InvitedBy]] has added you to the [[.OrgName]] organization in Grafana. -

    Once logged in, [[.OrgName]] will be available in the left side menu, in the dropdown below your username.

    -
    - - - - -
    Log in now
    -
    -
    - - diff --git a/emails/templates/invited_to_org.mjml b/emails/templates/invited_to_org.mjml new file mode 100644 index 00000000000..d85a011dbc5 --- /dev/null +++ b/emails/templates/invited_to_org.mjml @@ -0,0 +1,40 @@ + + + + + {{ Subject .Subject "{{ .InvitedBy }} has added you to the {{ .OrgName }} organization" }} + + + + + + + + + + +

    You have been added to {{ .OrgName }}

    + {{ .InvitedBy }} has added you to the {{ .OrgName }} organization in Grafana. +
    + + Once logged in, {{ .OrgName }} will be available to switch to in the user interface. + + + Log in now by clicking the link below: + + + Login to Grafana + + + You can also copy and paste this link into your browser directly: + + + {{ .AppUrl }} + +
    +
    + + + +
    +
    diff --git a/emails/templates/invited_to_org.txt b/emails/templates/invited_to_org.txt index 322119aa942..b2ae9e8a1ac 100644 --- a/emails/templates/invited_to_org.txt +++ b/emails/templates/invited_to_org.txt @@ -6,4 +6,4 @@ You have been added to [[.OrgName]] Once logged in, [[.OrgName]] will be available in the left side menu, in the dropdown below your username. Log in now: -[[.AppUrl]] \ No newline at end of file +[[.AppUrl]] diff --git a/emails/templates/layouts/default.html b/emails/templates/layouts/default.html deleted file mode 100644 index 7e580ac910f..00000000000 --- a/emails/templates/layouts/default.html +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - - - - - - - -
    -
    - - - - -
    -
    - - - - - -
    - - - - - - -
    - -
    - -
    - -
    -
    - - - - - - - - -
    - {{> body }} - -
    - - - - - - -
    -
    - - diff --git a/emails/templates/layouts/default.txt b/emails/templates/layouts/default.txt deleted file mode 100644 index 543f4ac140d..00000000000 --- a/emails/templates/layouts/default.txt +++ /dev/null @@ -1,3 +0,0 @@ -{{> body }} - -Sent by Grafana v[[.BuildVersion]] (c) 2022 Grafana Labs \ No newline at end of file diff --git a/emails/templates/new_user_invite.html b/emails/templates/new_user_invite.html deleted file mode 100644 index b2c0b431fd3..00000000000 --- a/emails/templates/new_user_invite.html +++ /dev/null @@ -1,49 +0,0 @@ - - -[[Subject .Subject "[[.InvitedBy]] has invited you to join Grafana"]] - - - - - -
    - - - - - - -
    -

    You're invited to join [[.OrgName]]

    -
    - -
    - - - - - -
    - - - - - - - - - - - -
    -

    You've been invited to join the [[.OrgName]] organization by [[.InvitedBy]]. To accept your invitation and join the team, please click the link below:

    -
    - - - - -
    Accept Invitation
    -
    -

    You can also copy and paste this link into your browser directly: [[.LinkUrl]]

    -
    -
    diff --git a/emails/templates/new_user_invite.mjml b/emails/templates/new_user_invite.mjml new file mode 100644 index 00000000000..66c28de9454 --- /dev/null +++ b/emails/templates/new_user_invite.mjml @@ -0,0 +1,36 @@ + + + + + {{ Subject .Subject "{{ .InvitedBy }} has invited you to join Grafana" }} + + + + + + + + + + +

    You're invited to join {{ .OrgName }}

    +
    + + You've been invited to join the {{ .OrgName }} organization by {{ .InvitedBy }}. To accept your invitation and join the team, please click the link below: + + + Accept Invitation + + + You can also copy and paste this link into your browser directly: + + + {{ .LinkUrl }} + +
    +
    + + + +
    +
    diff --git a/emails/templates/ng_alert_notification.html b/emails/templates/ng_alert_notification.html deleted file mode 100644 index 093c75cc21e..00000000000 --- a/emails/templates/ng_alert_notification.html +++ /dev/null @@ -1,271 +0,0 @@ - -
    - -[[Subject .Subject "[[.Title]]"]] - -[[ define "__text_values_list" ]][[ $len := len .Values ]][[ if $len ]][[ $first := gt $len 1 ]][[ range $refID, $value := .Values -]] -[[ $refID ]]=[[ $value ]][[ if $first ]], [[ end ]][[ $first = false ]][[ end -]] -[[ else ]][no value][[ end ]][[ end ]] - -[[ define "alert" ]] - - [[ if ne .ImageURL "" ]] - - - Alerting Panel - - - [[ end ]] - [[ if ne .EmbeddedImage "" ]] - - - Alerting Chart Attached Below - - - [[ end ]] - - - Value: [[ template "__text_values_list" . ]] - - - [[ if gt (len .Annotations.SortedPairs) 0 ]] - - - [[ range .Annotations.SortedPairs ]] -

    [[ .Name ]]: [[ .Value ]]

    - [[ end ]] - - - [[ end ]] - - - Labels: -
      - [[ range .Labels.SortedPairs ]]
    • [[ .Name ]]: [[ .Value ]]
    • [[ end ]] -
    - - - - - [[ if .SilenceURL ]] - - - Silence - - [[ end ]] - [[ if .Annotations.runbook_url ]] - - - View Runbook - - [[ end ]] - [[ if .DashboardURL]] - - - Go to Dashboard - - [[ end ]] - [[ if .PanelURL]] - - - Go to Panel - - [[ end ]] - [[ if gt (len .GeneratorURL) 0 ]]Source[[ end ]] - - - - -
    -
    -
    - - -[[ end ]] - -[[ if gt (len .Message) 0 ]] -
    [[ .Message ]] -[[ else ]] - - - - - - - -
    - - [[ if gt (len .Alerts.Firing) 0 ]] - - - - [[ range .Alerts.Firing ]] - - - - - [[ template "alert" . ]] - [[ end ]] - [[ end ]] - [[ if gt (len .Alerts.Resolved) 0 ]] - - - - [[ range .Alerts.Resolved ]] - - - - - [[ template "alert" . ]] - [[ end ]] - [[ end ]] - - - -
    - Firing: [[ .Alerts.Firing | len ]] alert[[ if gt (len .Alerts.Firing) 1 ]]s[[ end ]][[ if gt (len .GroupLabels.SortedPairs) 1 ]] for - [[ range .GroupLabels.SortedPairs ]] - [[ .Name ]]=[[ .Value ]] - [[ end ]][[ end ]] -
    - Firing - - [[ .Labels.alertname ]] -
    - Resolved: [[ .Alerts.Resolved | len ]] alert[[ if gt (len .Alerts.Resolved) 1 ]]s[[ end ]][[ if gt (len .GroupLabels.SortedPairs) 1 ]] for - [[ range .GroupLabels.SortedPairs ]] - [[ .Name ]]=[[ .Value ]] - [[ end ]][[ end ]] -
    - Resolved - - [[ .Labels.alertname ]] -
    - Go to alerts page -
    -
    -[[ end ]] - -
    diff --git a/emails/templates/ng_alert_notification.mjml b/emails/templates/ng_alert_notification.mjml new file mode 100644 index 00000000000..f5ff61069c4 --- /dev/null +++ b/emails/templates/ng_alert_notification.mjml @@ -0,0 +1,122 @@ + + + + + {{ Subject .Subject "{{ .Title }}" }} + + + + + + + + + + + + + + + + {{ if .Message }} + + + + + + + {{ range $line := (splitList "\n" .Message) }} + + {{ $line }}
    + + {{ end }} + +
    +
    +
    +
    + + + + {{ else }} + + + + + + + {{ if .Alerts.Firing }} + + + + + + +

    🔥 {{ .Alerts.Firing | len }} firing instances

    +
    +
    +
    + + + + {{ range .Alerts.Firing }} + + + + + + + + + + {{ end }} + + + + + {{ end }} + + + + + {{ if .Alerts.Resolved }} + + + + + +

    ✅ {{ .Alerts.Resolved | len }} resolved instances

    +
    +
    +
    + + + + {{ range .Alerts.Resolved }} + + + + + + + + + + {{ end }} + + + + + {{ end }} + + + + + {{ end }} + + + + + +
    +
    diff --git a/emails/templates/partials/alerting/firing_instance.mjml b/emails/templates/partials/alerting/firing_instance.mjml new file mode 100644 index 00000000000..9f392b5e788 --- /dev/null +++ b/emails/templates/partials/alerting/firing_instance.mjml @@ -0,0 +1,23 @@ + + + + Firing + + + + + {{ .Labels.alertname }} + + + + {{ if gt (len .GeneratorURL) 0 }} + + + + View alert + + + + {{ end }} + + diff --git a/emails/templates/partials/alerting/grouping_labels.mjml b/emails/templates/partials/alerting/grouping_labels.mjml new file mode 100644 index 00000000000..629351abbb0 --- /dev/null +++ b/emails/templates/partials/alerting/grouping_labels.mjml @@ -0,0 +1,30 @@ + + + + + {{ if eq (.GroupLabels.SortedPairs.Names | join ",") "alertname,grafana_folder" }} + + +

    📁 {{ .GroupLabels.grafana_folder }} › {{ .GroupLabels.alertname }}

    +
    + + {{ else }} + + + +

    + 📁 Grouped by  +

    + + {{ range .GroupLabels.SortedPairs }} + + {{ .Name }}={{ .Value }} + + {{ end }} + +
    + + {{ end }} + +
    +
    diff --git a/emails/templates/partials/alerting/instance_details.mjml b/emails/templates/partials/alerting/instance_details.mjml new file mode 100644 index 00000000000..26a4bbd7856 --- /dev/null +++ b/emails/templates/partials/alerting/instance_details.mjml @@ -0,0 +1,195 @@ + + + {{ if .ImageURL }} + + + + + + + + {{ end }} + + + + + {{ if .EmbeddedImage }} + + + + + + + + {{ end }} + + + + + + + {{ if .Annotations.summary }} + + + Summary + + + {{- .Annotations.summary -}} + + + {{ end }} + + + + + {{ if .Annotations.description }} + + + Description + + + + {{ range $line := (splitList "\n" .Annotations.description) }} + + {{ $line }}
    + + {{ end }} + +
    + + {{ end }} + +
    +
    + + + + {{ if .Values }} + + + + + Values + + + + + + + + {{ range $refID, $value := .Values }} + + {{ $refID }}={{ $value }}  + + {{ end }} + + + + + + {{ end }} + + + + + + + + {{ if .Labels.SortedPairs }} + + + Labels + + + + + {{ range .Labels.SortedPairs }} + + + + {{ .Name }} + + + {{ .Value }} + + + + {{ end }} + + + + + {{ end }} + + + + + {{ if .Annotations.SortedPairs }} + + + Annotations + + + + + {{ range .Annotations.SortedPairs }} + + + + {{ .Name }} + + + {{ .Value }} + + + + {{ end }} + + + + + {{ end }} + + + + + + + {{ if .SilenceURL }} + + + Silence + + + {{ end }} + {{ if .Annotations.runbook_url }} + + + View runbook + + + {{ end }} + {{ if .DashboardURL }} + + + View dashboard + + + {{ end }} + {{ if .PanelURL }} + + + View panel + + + {{ end }} + + + + + + + Observed {{ ago .StartsAt }} before this notification was delivered, at {{ .StartsAt }} + + + diff --git a/emails/templates/partials/alerting/resolved_instance.mjml b/emails/templates/partials/alerting/resolved_instance.mjml new file mode 100644 index 00000000000..5b9060f5922 --- /dev/null +++ b/emails/templates/partials/alerting/resolved_instance.mjml @@ -0,0 +1,23 @@ + + + + Resolved + + + + + {{ .Labels.alertname }} + + + + {{ if gt (len .GeneratorURL) 0 }} + + + + View alert + + + + {{ end }} + + diff --git a/emails/templates/partials/alerting/summary.mjml b/emails/templates/partials/alerting/summary.mjml new file mode 100644 index 00000000000..16d910fe437 --- /dev/null +++ b/emails/templates/partials/alerting/summary.mjml @@ -0,0 +1,28 @@ + + {{ $numberOfFiringInstance := (len .Alerts.Firing) }} + {{ $numberOfResolvedAlerts := (len .Alerts.Resolved) }} + + + + + {{ if $numberOfFiringInstance }} + + {{ $numberOfFiringInstance }} firing alert {{ $numberOfFiringInstance| plural "instance" "instances" }} + + {{ end }} + + + {{ if and $numberOfFiringInstance $numberOfResolvedAlerts }} + +  and  + + {{ end }} + + + {{ if $numberOfResolvedAlerts }} + + {{ $numberOfResolvedAlerts }} resolved alert {{ $numberOfResolvedAlerts| plural "instance" "instances" }} + + {{ end }} + + diff --git a/emails/templates/partials/layout/default.txt b/emails/templates/partials/layout/default.txt new file mode 100644 index 00000000000..a27a0817378 --- /dev/null +++ b/emails/templates/partials/layout/default.txt @@ -0,0 +1,3 @@ +{{> body }} + +Sent by Grafana v[[.BuildVersion]] (c) [[now | date "2006"]] Grafana Labs diff --git a/emails/templates/partials/layout/footer.mjml b/emails/templates/partials/layout/footer.mjml new file mode 100644 index 00000000000..fddf35962c8 --- /dev/null +++ b/emails/templates/partials/layout/footer.mjml @@ -0,0 +1,5 @@ + + + © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}. + + diff --git a/emails/templates/partials/layout/head.mjml b/emails/templates/partials/layout/head.mjml new file mode 100644 index 00000000000..83c9269b784 --- /dev/null +++ b/emails/templates/partials/layout/head.mjml @@ -0,0 +1,10 @@ + + + + + + + a { + color: #6E9FFF; + } + diff --git a/emails/templates/partials/layout/header.mjml b/emails/templates/partials/layout/header.mjml new file mode 100644 index 00000000000..15d50e0024b --- /dev/null +++ b/emails/templates/partials/layout/header.mjml @@ -0,0 +1,3 @@ + + + diff --git a/emails/templates/reset_password.html b/emails/templates/reset_password.html deleted file mode 100644 index e9d1527116c..00000000000 --- a/emails/templates/reset_password.html +++ /dev/null @@ -1,42 +0,0 @@ -[[Subject .Subject "Reset your Grafana password - [[.Name]]"]] - - - - - -
    - - - - - - -
    -

    Hi [[.Name]],

    -
    - -
    - - - - - -
    - - - - - -
    -

    - Please click the following link to reset your password within [[.EmailCodeValidHours]] hours. -

    -

    - [[.AppUrl]]user/password/reset?code=[[.Code]] -

    -

    Not working? Try copying and pasting it to your browser.

    -
    - -
    - - diff --git a/emails/templates/reset_password.mjml b/emails/templates/reset_password.mjml new file mode 100644 index 00000000000..f1face73715 --- /dev/null +++ b/emails/templates/reset_password.mjml @@ -0,0 +1,36 @@ + + + + + {{ Subject .Subject "Reset your Grafana password - {{.Name}}" }} + + + + + + + + + + +

    Hi {{ .Name }},

    +
    + + Please click the following link to reset your password within {{ .EmailCodeValidHours }} hours. + + + Reset Password + + + You can also copy and paste this link into your browser directly: + + + {{ .AppUrl }}user/password/reset?code={{ .Code }} + +
    +
    + + + +
    +
    diff --git a/emails/templates/signup_started.html b/emails/templates/signup_started.html deleted file mode 100644 index 3e8b3e0b976..00000000000 --- a/emails/templates/signup_started.html +++ /dev/null @@ -1,46 +0,0 @@ -[[Subject .Subject "Welcome to Grafana, please complete your sign up!"]] - - - - - -
    - - - - - - -
    -

    Complete the signup

    -
    - -
    - - - - - -
    - - - - - - - - -
    - Copy and paste the email verification code:
    - [[.Code]]
    in - the sign up form or use the link below. -
    - - - - -
    Complete Sign Up
    -
    -
    - - diff --git a/emails/templates/signup_started.mjml b/emails/templates/signup_started.mjml new file mode 100644 index 00000000000..98b85a6e955 --- /dev/null +++ b/emails/templates/signup_started.mjml @@ -0,0 +1,39 @@ + + + + + {{ Subject .Subject "Welcome to Grafana, please complete your sign up!" }} + + + + + + + + + + +

    Complete the signup

    +
    + + Copy and paste the email verification code in the sign up form or use the link below. + + + {{ .Code }} + + + Complete Sign Up + + + You can also copy and paste this link into your browser directly: + + + {{ .SignUpUrl }} + +
    +
    + + + +
    +
    diff --git a/emails/templates/welcome_on_signup.html b/emails/templates/welcome_on_signup.html deleted file mode 100644 index 7e2e004342a..00000000000 --- a/emails/templates/welcome_on_signup.html +++ /dev/null @@ -1,48 +0,0 @@ -[[Subject .Subject "Welcome to Grafana"]] - - - - - -
    - - - - - - - - - -
    -

    Hi [[.Name]],

    -
    - Welcome! Ready to start building some beautiful metric and analytic dashboards? -
    - -
    - - - - - -
    - - - - - - - - -
    -

    - If you are new to Grafana, refer to the Getting started with Grafana guide. -

    -
    - Thank you for joining our community. -
    -

    The Grafana Team

    -
    -
    - diff --git a/emails/templates/welcome_on_signup.mjml b/emails/templates/welcome_on_signup.mjml new file mode 100644 index 00000000000..642496c3762 --- /dev/null +++ b/emails/templates/welcome_on_signup.mjml @@ -0,0 +1,40 @@ + + + + + {{ Subject .Subject "Welcome to Grafana" }} + + + + + + + + + + +

    Hi {{ .Name }},

    +
    + + Welcome! Ready to start building some beautiful metric and analytic dashboards? + + + If you are new to Grafana, refer to the Getting started with Grafana + guide. + + + Check out our getting started guide + + + Thank you for joining our community. + + + The Grafana Team + +
    +
    + + + +
    +
    diff --git a/go.mod b/go.mod index 1e5d862639d..50dbe4cff3c 100644 --- a/go.mod +++ b/go.mod @@ -256,7 +256,6 @@ require ( github.com/grafana/codejen v0.0.3 github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f github.com/jmoiron/sqlx v1.3.5 - github.com/kr/pretty v0.3.0 github.com/matryer/is v1.4.0 github.com/parca-dev/parca v0.12.1 github.com/urfave/cli v1.22.9 @@ -271,6 +270,8 @@ require ( require ( cloud.google.com/go v0.102.0 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/armon/go-metrics v0.3.10 // indirect github.com/bmatcuk/doublestar v1.1.1 // indirect github.com/buildkite/yaml v2.1.0+incompatible // indirect @@ -287,15 +288,19 @@ require ( github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/memberlist v0.4.0 // indirect + github.com/huandu/xstrings v1.3.1 // indirect github.com/invopop/yaml v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-ieproxy v0.0.3 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.1.4 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/cast v1.3.1 // indirect go.starlark.net v0.0.0-20221020143700-22309ac47eac // indirect ) @@ -305,6 +310,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.2.2 github.com/Microsoft/go-winio v0.5.2 // indirect github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect github.com/RoaringBitmap/roaring v0.9.4 // indirect diff --git a/go.sum b/go.sum index 88e4e481756..e5f5e30bf67 100644 --- a/go.sum +++ b/go.sum @@ -234,11 +234,16 @@ github.com/HdrHistogram/hdrhistogram-go v1.0.1/go.mod h1:BWJ+nMSHY3L41Zj7CA3uXnl github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= +github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Masterminds/squirrel v0.0.0-20161115235646-20f192218cf5/go.mod h1:xnKTFzjGUiZtiOagBsfnvomW+nJg2usB1ZpordQWqNM= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= @@ -1507,6 +1512,8 @@ github.com/hetznercloud/hcloud-go v1.33.2 h1:ptWKVYLW7YtjXzsqTFKFxwpVo3iM9UMkVPB github.com/hodgesds/perf-utils v0.0.8/go.mod h1:F6TfvsbtrF88i++hou29dTXlI2sfsJv+gRZDtmTJkAs= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= +github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -1865,6 +1872,7 @@ github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -2265,6 +2273,7 @@ github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.6+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= @@ -2305,6 +2314,7 @@ github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52 github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= @@ -2667,6 +2677,7 @@ golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200422194213-44a606286825/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index da621f7e646..d29bc1273f0 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -189,9 +189,13 @@ func TestEmailNotifierIntegration(t *testing.T) { messageTmpl: "", expSubject: "[FIRING:2] ", expSnippets: []string{ - "Firing: 2 alerts", - "
  • alertname: FiringOne
  • severity: warning
  • ", - "
  • alertname: FiringTwo
  • severity: critical
  • ", + "2 firing instances", + "severity", + "warning\n", + "critical\n", + "alertname", + "FiringTwo\n", + "FiringOne\n", " - + + + - - - - - + + {{ Subject .Subject "{{ .InvitedBy }} has added you to the {{ .OrgName }} organization" }} + + + + + + + + + + + + + + + + + + - - - - -
    - - - - - - - - -
    -

    {{.InvitedBy}} has added you to the {{.OrgName}} organization in Grafana. -

    Once logged in, {{.OrgName}} will be available in the left side menu, in the dropdown below your username.

    -
    - - - - -
    Log in now
    -
    -
    - - - - - - - - - - - - - - - - - + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    +

    You have been added to {{ .OrgName }}

    + {{ .InvitedBy }} has added you to the {{ .OrgName }} organization in Grafana. +
    +
    +
    Once logged in, {{ .OrgName }} will be available to switch to in the user interface.
    +
    +
    Log in now by clicking the link below:
    +
    + + + + + + +
    + Login to Grafana +
    +
    +
    You can also copy and paste this link into your browser directly:
    +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
    +
    +
    + +
    +
    + +
    + diff --git a/public/emails/invited_to_org.txt b/public/emails/invited_to_org.txt index 6d4a8a3e74b..61982198bef 100644 --- a/public/emails/invited_to_org.txt +++ b/public/emails/invited_to_org.txt @@ -3,10 +3,10 @@ You have been added to {{.OrgName}} {{.InvitedBy}} has added you to the {{.OrgName}} organization in Grafana. -Once logged in, {{.OrgName}} will be available in the left side menu, in the dropdown -below your username. +Once logged in, {{.OrgName}} will be available in the left side menu, in the dropdown below your username. Log in now: {{.AppUrl}} -Sent by Grafana v{{.BuildVersion}} (c) 2022 Grafana Labs + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/emails/new_user_invite.html b/public/emails/new_user_invite.html index 0728cefffdc..dd70e3d50f7 100644 --- a/public/emails/new_user_invite.html +++ b/public/emails/new_user_invite.html @@ -1,286 +1,217 @@ - - + + + - - - - - + + {{ Subject .Subject "{{ .InvitedBy }} has invited you to join Grafana" }} + + + + + + + + + + + + + + + + + + - - - - -
    - - - - - - - - - - - -
    -

    You've been invited to join the {{.OrgName}} organization by {{.InvitedBy}}. To accept your invitation and join the team, please click the link below:

    -
    - - - - -
    Accept Invitation
    -
    -

    You can also copy and paste this link into your browser directly: {{.LinkUrl}}

    -
    -
    - - - - - - - - - - - - - - - + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + +
    +
    +

    You're invited to join {{ .OrgName }}

    +
    +
    +
    You've been invited to join the {{ .OrgName }} organization by {{ .InvitedBy }}. To accept your invitation and join the team, please click the link below:
    +
    + + + + + + +
    + Accept Invitation +
    +
    +
    You can also copy and paste this link into your browser directly:
    +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
    +
    +
    + +
    +
    + +
    + diff --git a/public/emails/new_user_invite.txt b/public/emails/new_user_invite.txt index b9b686b8839..2cc475a1052 100644 --- a/public/emails/new_user_invite.txt +++ b/public/emails/new_user_invite.txt @@ -2,10 +2,8 @@ You're invited to join {{.OrgName}} -You've been invited to join the {{.OrgName}} organization by {{.InvitedBy}}. To accept -your invitation and join the team, copy and paste the link below into your browser -directly: +You've been invited to join the {{.OrgName}} organization by {{.InvitedBy}}. To accept your invitation and join the team, copy and paste the link below into your browser directly: {{.LinkUrl}} -Sent by Grafana v{{.BuildVersion}} (c) 2022 Grafana Labs +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/emails/ng_alert_notification.html b/public/emails/ng_alert_notification.html index c0824fac335..d4858b482f9 100644 --- a/public/emails/ng_alert_notification.html +++ b/public/emails/ng_alert_notification.html @@ -1,386 +1,1315 @@ - - + + + - - - - - + + {{ Subject .Subject "{{ .Title }}" }} + + + + + + + + + + + + + + + + + + {{ $numberOfFiringInstance := (len .Alerts.Firing) }} + {{ $numberOfResolvedAlerts := (len .Alerts.Resolved) }} + + + +
    + + {{ if $numberOfFiringInstance }} + + {{ $numberOfFiringInstance }} firing alert {{ $numberOfFiringInstance| plural "instance" "instances" }} + {{ end }} - {{ if .Annotations.runbook_url }} - - - View Runbook - + + + {{ if and $numberOfFiringInstance $numberOfResolvedAlerts }} +  and  {{ end }} - {{ if .DashboardURL}} - - - Go to Dashboard - + + + {{ if $numberOfResolvedAlerts }} + + {{ $numberOfResolvedAlerts }} resolved alert {{ $numberOfResolvedAlerts| plural "instance" "instances" }} + {{ end }} - {{ if .PanelURL}} - - - Go to Panel - - {{ end }} - {{ if gt (len .GeneratorURL) 0 }}Source{{ end }} - - - - -
    -
    -
    - - -{{ end }} - -{{ if gt (len .Message) 0 }} -
    {{ .Message }} -{{ else }} - - - - - - - -
    - - {{ if gt (len .Alerts.Firing) 0 }} - - - - {{ range .Alerts.Firing }} - - - - - {{ template "alert" . }} - {{ end }} - {{ end }} - {{ if gt (len .Alerts.Resolved) 0 }} - - - - {{ range .Alerts.Resolved }} - - - - - {{ template "alert" . }} - {{ end }} - {{ end }} - - - -
    - Firing: {{ .Alerts.Firing | len }} alert{{ if gt (len .Alerts.Firing) 1 }}s{{ end }}{{ if gt (len .GroupLabels.SortedPairs) 1 }} for - {{ range .GroupLabels.SortedPairs }} - {{ .Name }}={{ .Value }} - {{ end }}{{ end }} -
    - Firing - - {{ .Labels.alertname }} -
    - Resolved: {{ .Alerts.Resolved | len }} alert{{ if gt (len .Alerts.Resolved) 1 }}s{{ end }}{{ if gt (len .GroupLabels.SortedPairs) 1 }} for - {{ range .GroupLabels.SortedPairs }} - {{ .Name }}={{ .Value }} - {{ end }}{{ end }} -
    - Resolved - - {{ .Labels.alertname }} -
    - Go to alerts page + + +
    + +
    + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    +
    -
    -{{ end }} - - - - -
    - - - - - - - - - - +
    + +
    + + + + + + +
    + +
    + + + {{ if eq (.GroupLabels.SortedPairs.Names | join ",") "alertname,grafana_folder" }} + + + + {{ else }} + + + + {{ end }} + +
    +
    +

    📁 {{ .GroupLabels.grafana_folder }} › {{ .GroupLabels.alertname }}

    +
    +
    +
    +

    📁 Grouped by 

    + + {{ range .GroupLabels.SortedPairs }} + + {{ .Name }}={{ .Value }} + + {{ end }} + +
    +
    +
    + +
    +
    + + {{ if .Message }} + +
    + + + + + + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    + + {{ range $line := (splitList "\n" .Message) }} + + {{ $line }}
    + + {{ end }} + +
    +
    +
    + +
    +
    + +
    +
    + + {{ else }}{{ if .Alerts.Firing }} + +
    + + + + + + +
    + +
    + + + + + + +
    +
    +

    🔥 {{ .Alerts.Firing | len }} firing instances

    +
    +
    +
    + +
    +
    + + {{ range .Alerts.Firing }} + +
    + + + + + + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + Firing +
    +
    +
    + +
    + + + + + + +
    +
    {{ .Labels.alertname }}
    +
    +
    + + {{ if gt (len .GeneratorURL) 0 }} + +
    + + + + + + +
    + + + + + + +
    + View alert +
    +
    +
    + + {{ end }} + +
    +
    + + {{ if .ImageURL }} + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + + + +
    +
    +
    + +
    +
    + + {{ end }}{{ if .EmbeddedImage }} + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + + {{ end }} + +
    + + + + + + +
    + +
    + + + {{ if .Annotations.summary }} + + + + + + + {{ end }}{{ if .Annotations.description }} + + + + + + + {{ end }} + +
    +
    Summary
    +
    +
    {{- .Annotations.summary -}}
    +
    +
    Description
    +
    +
    + + {{ range $line := (splitList "\n" .Annotations.description) }} + + {{ $line }}
    + + {{ end }} + +
    +
    +
    + +
    +
    + + {{ if .Values }} + +
    + + + + + + +
    + +
    + + + + + + +
    +
    Values
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    +
    + + {{ range $refID, $value := .Values }} + + {{ $refID }}={{ $value }}  + {{ end }} + +
    +
    +
    +
    + +
    +
    + + {{ end }} + +
    + + + + + + +
    + +
    + + + {{ if .Labels.SortedPairs }} + + + + + + + {{ end }}{{ if .Annotations.SortedPairs }} + + + + + + + {{ end }} + +
    +
    Labels
    +
    + + + {{ range .Labels.SortedPairs }} + + + + + + + {{ end }} + +
    + {{ .Name }} + + {{ .Value }} +
    +
    +
    Annotations
    +
    + + + {{ range .Annotations.SortedPairs }} + + + + + + + {{ end }} + +
    + {{ .Name }} + + {{ .Value }} +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + + {{ if .SilenceURL }} + +
    + + + + + + +
    + + + + + + +
    + Silence +
    +
    +
    + + {{ end }}{{ if .Annotations.runbook_url }} + +
    + + + + + + +
    + + + + + + +
    + View runbook +
    +
    +
    + + {{ end }}{{ if .DashboardURL }} + +
    + + + + + + +
    + + + + + + +
    + View dashboard +
    +
    +
    + + {{ end }}{{ if .PanelURL }} + +
    + + + + + + +
    + + + + + + +
    + View panel +
    +
    +
    + + {{ end }} + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    Observed {{ ago .StartsAt }} before this notification was delivered, at {{ .StartsAt }}
    +
    +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    +
    + + {{ end }}{{ end }}{{ if .Alerts.Resolved }} + +
    + + + + + + +
    + +
    + + + + + + +
    +
    +

    ✅ {{ .Alerts.Resolved | len }} resolved instances

    +
    +
    +
    + +
    +
    + + {{ range .Alerts.Resolved }} + +
    + + + + + + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    +

    Resolved

    +
    +
    +
    + +
    + + + + + + +
    +
    {{ .Labels.alertname }}
    +
    +
    + + {{ if gt (len .GeneratorURL) 0 }} + +
    + + + + + + +
    + + + + + + +
    + View alert +
    +
    +
    + + {{ end }} + +
    +
    + + {{ if .ImageURL }} + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + + + +
    +
    +
    + +
    +
    + + {{ end }}{{ if .EmbeddedImage }} + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + + {{ end }} + +
    + + + + + + +
    + +
    + + + {{ if .Annotations.summary }} + + + + + + + {{ end }}{{ if .Annotations.description }} + + + + + + + {{ end }} + +
    +
    Summary
    +
    +
    {{- .Annotations.summary -}}
    +
    +
    Description
    +
    +
    + + {{ range $line := (splitList "\n" .Annotations.description) }} + + {{ $line }}
    + + {{ end }} + +
    +
    +
    + +
    +
    + + {{ if .Values }} + +
    + + + + + + +
    + +
    + + + + + + +
    +
    Values
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    +
    + + {{ range $refID, $value := .Values }} + + {{ $refID }}={{ $value }}  + {{ end }} + +
    +
    +
    +
    + +
    +
    + + {{ end }} + +
    + + + + + + +
    + +
    + + + {{ if .Labels.SortedPairs }} + + + + + + + {{ end }}{{ if .Annotations.SortedPairs }} + + + + + + + {{ end }} + +
    +
    Labels
    +
    + + + {{ range .Labels.SortedPairs }} + + + + + + + {{ end }} + +
    + {{ .Name }} + + {{ .Value }} +
    +
    +
    Annotations
    +
    + + + {{ range .Annotations.SortedPairs }} + + + + + + + {{ end }} + +
    + {{ .Name }} + + {{ .Value }} +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + + {{ if .SilenceURL }} + +
    + + + + + + +
    + + + + + + +
    + Silence +
    +
    +
    + + {{ end }}{{ if .Annotations.runbook_url }} + +
    + + + + + + +
    + + + + + + +
    + View runbook +
    +
    +
    + + {{ end }}{{ if .DashboardURL }} + +
    + + + + + + +
    + + + + + + +
    + View dashboard +
    +
    +
    + + {{ end }}{{ if .PanelURL }} + +
    + + + + + + +
    + + + + + + +
    + View panel +
    +
    +
    + + {{ end }} + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    Observed {{ ago .StartsAt }} before this notification was delivered, at {{ .StartsAt }}
    +
    +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    +
    + + {{ end }}{{ end }}{{ end }} + +
    + + + + + + +
    + +
    + + + + + + +
    +
    © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
    +
    +
    + +
    +
    + +
    + diff --git a/public/emails/ng_alert_notification.txt b/public/emails/ng_alert_notification.txt index 485bdfe3f07..be157396c17 100644 --- a/public/emails/ng_alert_notification.txt +++ b/public/emails/ng_alert_notification.txt @@ -19,8 +19,7 @@ Annotations: {{ range .Annotations.SortedPairs }} {{ .Name }} = {{ .Value }} {{ end }} -{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}({{ .Alerts.Resolved | len }}) Resolved{{ end -}} +{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}({{ .Alerts.Resolved | len }}) Resolved{{ end }} {{ range .Alerts.Resolved }} Labels: {{ range .Labels.SortedPairs }} @@ -38,4 +37,5 @@ Annotations: Go to the Alerts page: {{.AlertPageUrl}} -Sent by Grafana v{{.BuildVersion}} (c) 2022 Grafana Labs + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/emails/reset_password.html b/public/emails/reset_password.html index ab4ef1c761c..b064b21c180 100644 --- a/public/emails/reset_password.html +++ b/public/emails/reset_password.html @@ -1,279 +1,217 @@ - - + + + - - - - - + + {{ Subject .Subject "Reset your Grafana password - {{.Name}}" }} + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + +
    +
    +

    Hi {{ .Name }},

    +
    +
    +
    Please click the following link to reset your password within {{ .EmailCodeValidHours }} hours.
    +
    + + + + + + +
    + Reset Password +
    +
    +
    You can also copy and paste this link into your browser directly:
    +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
    +
    +
    + +
    +
    + +
    + diff --git a/public/emails/reset_password.txt b/public/emails/reset_password.txt index 0c5397416fc..0b15365f391 100644 --- a/public/emails/reset_password.txt +++ b/public/emails/reset_password.txt @@ -2,8 +2,8 @@ Hi {{.Name}}, -Copy and paste the following link directly in your browser to reset your password within -{{.EmailCodeValidHours}} hours. +Copy and paste the following link directly in your browser to reset your password within {{.EmailCodeValidHours}} hours. {{.AppUrl}}user/password/reset?code={{.Code}} -Sent by Grafana v{{.BuildVersion}} (c) 2022 Grafana Labs + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/emails/signup_started.html b/public/emails/signup_started.html index 15f1945862d..48d4c6236df 100644 --- a/public/emails/signup_started.html +++ b/public/emails/signup_started.html @@ -1,283 +1,232 @@ - - + + + - - - - - + + {{ Subject .Subject "Welcome to Grafana, please complete your sign up!" }} + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    +

    Complete the signup

    +
    +
    +
    Copy and paste the email verification code in the sign up form or use the link below.
    +
    + + + + + + +
    +

    + {{ .Code }} +

    +
    +
    + + + + + + +
    + Complete Sign Up +
    +
    +
    You can also copy and paste this link into your browser directly:
    +
    + +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
    +
    +
    + +
    +
    + +
    + diff --git a/public/emails/signup_started.txt b/public/emails/signup_started.txt index 94dd8547936..a40e7403ecd 100644 --- a/public/emails/signup_started.txt +++ b/public/emails/signup_started.txt @@ -8,4 +8,5 @@ in the sign up form or use the link below. {{.SignUpUrl}} -Sent by Grafana v{{.BuildVersion}} (c) 2022 Grafana Labs + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/emails/welcome_on_signup.html b/public/emails/welcome_on_signup.html index 28ac926fb06..a532fc8a58a 100644 --- a/public/emails/welcome_on_signup.html +++ b/public/emails/welcome_on_signup.html @@ -1,285 +1,222 @@ - - + + + - - - - - + + {{ Subject .Subject "Welcome to Grafana" }} + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + +
    + +
    + + + + + + +
    + +
    + + + + + + +
    + + + + + + +
    + +
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    +

    Hi {{ .Name }},

    +
    +
    +
    Welcome! Ready to start building some beautiful metric and analytic dashboards?
    +
    +
    If you are new to Grafana, refer to the Getting started with Grafana guide.
    +
    + + + + + + +
    + Check out our getting started guide +
    +
    +
    Thank you for joining our community.
    +
    +
    The Grafana Team
    +
    +
    + +
    +
    + +
    + + + + + + +
    + +
    + + + + + + +
    +
    © {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
    +
    +
    + +
    +
    + +
    + diff --git a/public/emails/welcome_on_signup.txt b/public/emails/welcome_on_signup.txt index 31bd3632997..4608fa9e82e 100644 --- a/public/emails/welcome_on_signup.txt +++ b/public/emails/welcome_on_signup.txt @@ -4,11 +4,11 @@ Hi {{.Name}}, Welcome! Ready to start building some beautiful metric and analytic dashboards? -If you are new to Grafana, refer to the Getting started with Grafana guide on -https://grafana.com/docs/grafana/latest/getting-started/getting-started/. +If you are new to Grafana, refer to the Getting started with Grafana guide on https://grafana.com/docs/grafana/latest/getting-started/getting-started/. Thank you for joining our community. The Grafana team -Sent by Grafana v{{.BuildVersion}} (c) 2022 Grafana Labs + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs From fef1e1d5bc8c1ea7d6deb6a8ef808988cdec61aa Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Fri, 18 Nov 2022 09:56:06 +0100 Subject: [PATCH 294/926] Auth: Refactor auth package (#58920) * Auth: move interface to its own file * Auth: move to test package * Auth: move quota consts to auth file * Auth: move service to impl package * Auth: move interfaces and related models to auth package * Auth: Create sub package and type alias to avoid circular dependency --- pkg/api/admin_users.go | 7 +-- pkg/api/admin_users_test.go | 13 ++--- pkg/api/common_test.go | 6 +-- pkg/api/http_server.go | 5 +- pkg/api/ldap_debug_test.go | 4 +- pkg/api/login.go | 7 +-- pkg/api/login_test.go | 12 ++--- pkg/api/user_token.go | 13 ++--- pkg/api/user_token_test.go | 41 +++++++------- pkg/cmd/grafana-cli/runner/wire.go | 5 +- pkg/cmd/grafana-cli/runner/wireexts_oss.go | 7 +-- pkg/middleware/auth.go | 5 +- pkg/middleware/middleware_test.go | 39 +++++++------- pkg/middleware/org_redirect_test.go | 10 ++-- pkg/middleware/quota_test.go | 6 +-- pkg/middleware/recovery_test.go | 4 +- pkg/middleware/testing.go | 6 +-- pkg/models/context.go | 3 +- pkg/models/usertoken/user_token.go | 26 +++++++++ .../backgroundsvcs/background_services.go | 4 +- pkg/server/wire.go | 5 +- pkg/server/wireexts_oss.go | 7 +-- pkg/services/accesscontrol/middleware.go | 6 +-- .../user_token.go => services/auth/auth.go} | 40 ++++++-------- .../auth/{ => authimpl}/auth_token.go | 54 +++++++++---------- .../auth/{ => authimpl}/auth_token_test.go | 26 ++++----- pkg/services/auth/{ => authimpl}/model.go | 16 ++---- .../auth/{ => authimpl}/token_cleanup.go | 2 +- .../auth/{ => authimpl}/token_cleanup_test.go | 2 +- pkg/services/auth/{ => authtest}/testing.go | 47 ++++++++-------- .../contexthandler/auth_proxy_test.go | 4 +- pkg/services/contexthandler/contexthandler.go | 10 ++-- .../contexthandler/contexthandler_test.go | 18 ++++--- pkg/services/ngalert/api/util_test.go | 3 +- pkg/services/quota/quotaimpl/quota_test.go | 3 +- 35 files changed, 245 insertions(+), 221 deletions(-) create mode 100644 pkg/models/usertoken/user_token.go rename pkg/{models/user_token.go => services/auth/auth.go} (74%) rename pkg/services/auth/{ => authimpl}/auth_token.go (91%) rename pkg/services/auth/{ => authimpl}/auth_token_test.go (96%) rename pkg/services/auth/{ => authimpl}/model.go (77%) rename pkg/services/auth/{ => authimpl}/token_cleanup.go (99%) rename pkg/services/auth/{ => authimpl}/token_cleanup_test.go (99%) rename pkg/services/auth/{ => authtest}/testing.go (77%) diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index e0b244bfab4..bf284cddb33 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -416,7 +417,7 @@ func (hs *HTTPServer) AdminGetUserAuthTokens(c *models.ReqContext) response.Resp // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) AdminRevokeUserAuthToken(c *models.ReqContext) response.Response { - cmd := models.RevokeAuthTokenCmd{} + cmd := auth.RevokeAuthTokenCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } @@ -476,7 +477,7 @@ type AdminLogoutUserParams struct { type AdminRevokeUserAuthTokenParams struct { // in:body // required:true - Body models.RevokeAuthTokenCmd `json:"body"` + Body auth.RevokeAuthTokenCmd `json:"body"` // in:path // required:true UserID int64 `json:"user_id"` @@ -508,5 +509,5 @@ type AdminCreateUserResponseResponse struct { // swagger:response adminGetUserAuthTokensResponse type AdminGetUserAuthTokensResponse struct { // in:body - Body []*models.UserToken `json:"body"` + Body []*auth.UserToken `json:"body"` } diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 41f79831f74..3de90077df9 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" "github.com/grafana/grafana/pkg/services/org" @@ -65,7 +66,7 @@ func TestAdminAPIEndpoint(t *testing.T) { }) t.Run("When a server admin attempts to revoke an auth token for a non-existing user", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} mockUser := usertest.NewUserServiceFake() mockUser.ExpectedError = user.ErrUserNotFound adminRevokeUserAuthTokenScenario(t, "Should return not found when calling POST on", @@ -263,7 +264,7 @@ func putAdminScenario(t *testing.T, desc string, url string, routePattern string func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, userService *usertest.FakeUserService) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := HTTPServer{ - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), userService: userService, } @@ -285,9 +286,9 @@ func adminLogoutUserScenario(t *testing.T, desc string, url string, routePattern }) } -func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, fn scenarioFunc, userService user.Service) { +func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd auth.RevokeAuthTokenCmd, fn scenarioFunc, userService user.Service) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -315,7 +316,7 @@ func adminRevokeUserAuthTokenScenario(t *testing.T, desc string, url string, rou func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, fn scenarioFunc, userService *usertest.FakeUserService) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -341,7 +342,7 @@ func adminGetUserAuthTokensScenario(t *testing.T, desc string, url string, route func adminDisableUserScenario(t *testing.T, desc string, action string, url string, routePattern string, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() authInfoService := &logintest.AuthInfoServiceFake{} diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index e6f0c159401..39be06adbc5 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -28,7 +28,7 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" @@ -181,7 +181,7 @@ type scenarioContext struct { defaultHandler web.Handler req *http.Request url string - userAuthTokenService *auth.FakeUserAuthTokenService + userAuthTokenService *authtest.FakeUserAuthTokenService sqlStore sqlstore.Store authInfoService *logintest.AuthInfoServiceFake dashboardVersionService dashver.Service @@ -207,7 +207,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa cfg.RemoteCacheOptions = &setting.RemoteCacheOptions{ Name: "database", } - userAuthTokenSvc := auth.NewFakeUserAuthTokenService() + userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} authJWTSvc := models.NewFakeJWTService() tracer := tracing.InitializeTracerForTest() diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f2cfce24fc1..7320b71b2cd 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware/csrf" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/querylibrary" @@ -120,7 +121,7 @@ type HTTPServer struct { navTreeService navtree.Service CacheService *localcache.CacheService DataSourceCache datasources.CacheService - AuthTokenService models.UserTokenService + AuthTokenService auth.UserTokenService QuotaService quota.Service RemoteCacheService *remotecache.RemoteCache ProvisioningService provisioning.ProvisioningService @@ -220,7 +221,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver, pluginDashboardService plugindashboards.Service, pluginStore plugins.Store, pluginClient plugins.Client, pluginErrorResolver plugins.ErrorResolver, pluginInstaller plugins.Installer, settingsProvider setting.Provider, - dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, + dataSourceCache datasources.CacheService, userTokenService auth.UserTokenService, cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service, correlationsService correlations.Service, thumbService thumbs.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, loginService login.Service, authenticator loginpkg.Authenticator, accessControl accesscontrol.AccessControl, diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 5a93f53e3ba..95b92e4e06f 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" @@ -379,7 +379,7 @@ func postSyncUserWithLDAPContext(t *testing.T, requestURL string, preHook func(* hs := &HTTPServer{ Cfg: sc.cfg, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), Login: loginservice.LoginServiceMock{}, authInfoService: sc.authInfoService, userService: userService, diff --git a/pkg/api/login.go b/pkg/api/login.go index 20baa31bde5..9b23ad05b6e 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" loginService "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/user" @@ -227,7 +228,7 @@ func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response { err = hs.loginUserWithUser(usr, c) if err != nil { - var createTokenErr *models.CreateTokenErr + var createTokenErr *auth.CreateTokenErr if errors.As(err, &createTokenErr) { resp = response.Error(createTokenErr.StatusCode, createTokenErr.ExternalErr, createTokenErr.InternalErr) } else { @@ -299,7 +300,7 @@ func (hs *HTTPServer) Logout(c *models.ReqContext) { } err := hs.AuthTokenService.RevokeToken(c.Req.Context(), c.UserToken, false) - if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { + if err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) { hs.log.Error("failed to revoke auth token", "error", err) } @@ -370,7 +371,7 @@ func (hs *HTTPServer) samlSingleLogoutEnabled() bool { } func getLoginExternalError(err error) string { - var createTokenErr *models.CreateTokenErr + var createTokenErr *auth.CreateTokenErr if errors.As(err, &createTokenErr) { return createTokenErr.ExternalErr } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 27452b9bd09..ea30addb451 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -12,8 +12,6 @@ import ( "strings" "testing" - loginservice "github.com/grafana/grafana/pkg/services/login" - "github.com/grafana/grafana/pkg/services/navtree" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,9 +23,11 @@ import ( "github.com/grafana/grafana/pkg/login" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" + loginservice "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -323,7 +323,7 @@ func TestLoginPostRedirect(t *testing.T) { Cfg: setting.NewCfg(), HooksService: &hooks.HooksService{}, License: &licensing.OSSLicensingService{}, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), } hs.Cfg.CookieSecure = true @@ -564,7 +564,7 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte Cfg: sc.cfg, SettingsProvider: &setting.OSSImpl{Cfg: sc.cfg}, License: &licensing.OSSLicensingService{}, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), log: log.New("hello"), SocialService: &mockSocialService{}, } @@ -602,7 +602,7 @@ func TestLoginPostRunLokingHook(t *testing.T) { log: log.New("test"), Cfg: setting.NewCfg(), License: &licensing.OSSLicensingService{}, - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), HooksService: hookService, } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 772cb0ff183..3e12fca2d2e 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -43,7 +44,7 @@ func (hs *HTTPServer) GetUserAuthTokens(c *models.ReqContext) response.Response // 403: forbiddenError // 500: internalServerError func (hs *HTTPServer) RevokeUserAuthToken(c *models.ReqContext) response.Response { - cmd := models.RevokeAuthTokenCmd{} + cmd := auth.RevokeAuthTokenCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } @@ -143,7 +144,7 @@ func (hs *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int return response.JSON(http.StatusOK, result) } -func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) response.Response { +func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd auth.RevokeAuthTokenCmd) response.Response { userQuery := user.GetUserByIDQuery{ID: userID} _, err := hs.userService.GetByID(c.Req.Context(), &userQuery) if err != nil { @@ -155,7 +156,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i token, err := hs.AuthTokenService.GetUserToken(c.Req.Context(), userID, cmd.AuthTokenId) if err != nil { - if errors.Is(err, models.ErrUserTokenNotFound) { + if errors.Is(err, auth.ErrUserTokenNotFound) { return response.Error(404, "User auth token not found", err) } return response.Error(500, "Failed to get user auth token", err) @@ -167,7 +168,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, false) if err != nil { - if errors.Is(err, models.ErrUserTokenNotFound) { + if errors.Is(err, auth.ErrUserTokenNotFound) { return response.Error(404, "User auth token not found", err) } return response.Error(500, "Failed to revoke user auth token", err) @@ -182,11 +183,11 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i type RevokeUserAuthTokenParams struct { // in:body // required:true - Body models.RevokeAuthTokenCmd `json:"body"` + Body auth.RevokeAuthTokenCmd `json:"body"` } // swagger:response getUserAuthTokensResponse type GetUserAuthTokensResponse struct { // in:body - Body []*models.UserToken `json:"body"` + Body []*auth.UserToken `json:"body"` } diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go index 2dd7d7e7fbd..093a27011b9 100644 --- a/pkg/api/user_token_test.go +++ b/pkg/api/user_token_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/usertest" @@ -20,7 +21,7 @@ import ( func TestUserTokenAPIEndpoint(t *testing.T) { userMock := usertest.NewUserServiceFake() t.Run("When current user attempts to revoke an auth token for a non-existing user", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} userMock.ExpectedError = user.ErrUserNotFound revokeUserAuthTokenScenario(t, "Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) { @@ -59,15 +60,15 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) t.Run("When revoke an auth token for a user", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} - token := &models.UserToken{Id: 1} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &auth.UserToken{Id: 1} mockUser := &usertest.FakeUserService{ ExpectedUser: &user.User{ID: 200}, } revokeUserAuthTokenInternalScenario(t, "Should be successful", cmd, 200, token, func(sc *scenarioContext) { - sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { - return &models.UserToken{Id: 2}, nil + sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { + return &auth.UserToken{Id: 2}, nil } sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() assert.Equal(t, 200, sc.resp.Code) @@ -75,11 +76,11 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) t.Run("When revoke the active auth token used by himself", func(t *testing.T) { - cmd := models.RevokeAuthTokenCmd{AuthTokenId: 2} - token := &models.UserToken{Id: 2} + cmd := auth.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &auth.UserToken{Id: 2} mockUser := usertest.NewUserServiceFake() revokeUserAuthTokenInternalScenario(t, "Should not be successful", cmd, testUserID, token, func(sc *scenarioContext) { - sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { + sc.userAuthTokenService.GetUserTokenProvider = func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { return token, nil } sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() @@ -88,10 +89,10 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) t.Run("When gets auth tokens for a user", func(t *testing.T) { - currentToken := &models.UserToken{Id: 1} + currentToken := &auth.UserToken{Id: 1} mockUser := usertest.NewUserServiceFake() getUserAuthTokensInternalScenario(t, "Should be successful", currentToken, func(sc *scenarioContext) { - tokens := []*models.UserToken{ + tokens := []*auth.UserToken{ { Id: 1, ClientIp: "127.0.0.1", @@ -107,7 +108,7 @@ func TestUserTokenAPIEndpoint(t *testing.T) { SeenAt: 0, }, } - sc.userAuthTokenService.GetUserTokensProvider = func(ctx context.Context, userId int64) ([]*models.UserToken, error) { + sc.userAuthTokenService.GetUserTokensProvider = func(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return tokens, nil } sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -145,10 +146,10 @@ func TestUserTokenAPIEndpoint(t *testing.T) { }) } -func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd models.RevokeAuthTokenCmd, +func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePattern string, cmd auth.RevokeAuthTokenCmd, userId int64, fn scenarioFunc, userService user.Service) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -175,7 +176,7 @@ func revokeUserAuthTokenScenario(t *testing.T, desc string, url string, routePat func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePattern string, userId int64, fn scenarioFunc, userService user.Service) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -202,7 +203,7 @@ func getUserAuthTokensScenario(t *testing.T, desc string, url string, routePatte func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId int64, fn scenarioFunc, userService user.Service) { t.Run(desc, func(t *testing.T) { hs := HTTPServer{ - AuthTokenService: auth.NewFakeUserAuthTokenService(), + AuthTokenService: authtest.NewFakeUserAuthTokenService(), userService: userService, } @@ -222,10 +223,10 @@ func logoutUserFromAllDevicesInternalScenario(t *testing.T, desc string, userId }) } -func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.RevokeAuthTokenCmd, userId int64, - token *models.UserToken, fn scenarioFunc, userService user.Service) { +func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd auth.RevokeAuthTokenCmd, userId int64, + token *auth.UserToken, fn scenarioFunc, userService user.Service) { t.Run(desc, func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, @@ -248,9 +249,9 @@ func revokeUserAuthTokenInternalScenario(t *testing.T, desc string, cmd models.R }) } -func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *models.UserToken, fn scenarioFunc, userService user.Service) { +func getUserAuthTokensInternalScenario(t *testing.T, desc string, token *auth.UserToken, fn scenarioFunc, userService user.Service) { t.Run(desc, func(t *testing.T) { - fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + fakeAuthTokenService := authtest.NewFakeUserAuthTokenService() hs := HTTPServer{ AuthTokenService: fakeAuthTokenService, diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 82efb1ce69e..8ee0af3f6a5 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -7,6 +7,7 @@ import ( "context" "github.com/google/wire" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/tsdb/parca" "github.com/grafana/grafana/pkg/tsdb/phlare" @@ -253,8 +254,8 @@ var wireSet = wire.NewSet( influxdb.ProvideService, wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, - auth.ProvideActiveAuthTokenService, - wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + authimpl.ProvideActiveAuthTokenService, + wire.Bind(new(auth.ActiveTokenService), new(*authimpl.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/cmd/grafana-cli/runner/wireexts_oss.go b/pkg/cmd/grafana-cli/runner/wireexts_oss.go index 407ce3c1945..dfc36b3cd26 100644 --- a/pkg/cmd/grafana-cli/runner/wireexts_oss.go +++ b/pkg/cmd/grafana-cli/runner/wireexts_oss.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" @@ -48,9 +49,9 @@ var wireExtsSet = wire.NewSet( wire.Bind(new(setting.Provider), new(*setting.OSSImpl)), osskmsproviders.ProvideService, wire.Bind(new(kmsproviders.Service), new(osskmsproviders.Service)), - auth.ProvideUserAuthTokenService, - wire.Bind(new(models.UserTokenService), new(*auth.UserAuthTokenService)), - wire.Bind(new(models.UserTokenBackgroundService), new(*auth.UserAuthTokenService)), + authimpl.ProvideUserAuthTokenService, + wire.Bind(new(auth.UserTokenService), new(*authimpl.UserAuthTokenService)), + wire.Bind(new(auth.UserTokenBackgroundService), new(*authimpl.UserAuthTokenService)), acimpl.ProvideService, wire.Bind(new(accesscontrol.Service), new(*acimpl.Service)), wire.Bind(new(accesscontrol.RoleRegistry), new(*acimpl.Service)), diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 7ab6274bc4c..b7e00465586 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" @@ -42,7 +43,7 @@ func notAuthorized(c *models.ReqContext) { c.Redirect(setting.AppSubUrl + "/login") } -func tokenRevoked(c *models.ReqContext, err *models.TokenRevokedError) { +func tokenRevoked(c *models.ReqContext, err *auth.TokenRevokedError) { if c.IsApiRequest() { c.JSON(401, map[string]interface{}{ "message": "Token revoked", @@ -117,7 +118,7 @@ func Auth(options *AuthOptions) web.Handler { requireLogin := !c.AllowAnonymous || forceLogin || options.ReqNoAnonynmous if !c.IsSignedIn && options.ReqSignedIn && requireLogin { - var revokedErr *models.TokenRevokedError + var revokedErr *auth.TokenRevokedError if errors.As(c.LookupTokenErr, &revokedErr) { tokenRevoked(c, revokedErr) return diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index fec44973196..94efe1c7ee4 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeytest" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -264,8 +265,8 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -288,14 +289,14 @@ func TestMiddlewareContext(t *testing.T) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: "", }, nil } - sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, + sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil @@ -371,8 +372,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Invalid/expired auth token in cookie", func(t *testing.T, sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return nil, models.ErrUserTokenNotFound + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return nil, auth.ErrUserTokenNotFound } sc.fakeReq("GET", "/").exec() @@ -391,8 +392,8 @@ func TestMiddlewareContext(t *testing.T) { sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(11 * time.Second)} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -424,8 +425,8 @@ func TestMiddlewareContext(t *testing.T) { OAuthRefreshToken: "refresh_token"} sc.oauthTokenService.ExpectedErrors = map[string]error{"TryTokenRefresh": errors.New("error")} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -454,8 +455,8 @@ func TestMiddlewareContext(t *testing.T) { sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID, OAuthExpiry: fakeGetTime()().Add(-5 * time.Second), OAuthRefreshToken: "refreshtoken"} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -481,8 +482,8 @@ func TestMiddlewareContext(t *testing.T) { sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 2, UserID: userID} sc.oauthTokenService.ExpectedAuthUser = &models.UserAuth{UserId: userID} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: userID, UnhashedToken: unhashedToken, }, nil @@ -819,14 +820,14 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( sc.userService = usertest.NewUserServiceFake() sc.orgService = orgtest.NewOrgServiceFake() sc.apiKeyService = &apikeytest.Service{} - sc.oauthTokenService = &auth.FakeOAuthTokenService{} + sc.oauthTokenService = &authtest.FakeOAuthTokenService{} ctxHdlr := getContextHandler(t, cfg, sc.mockSQLStore, sc.loginService, sc.apiKeyService, sc.userService, sc.orgService, sc.oauthTokenService) sc.sqlStore = ctxHdlr.SQLStore sc.contextHandler = ctxHdlr sc.m.Use(ctxHdlr.Middleware) sc.m.Use(OrgRedirect(sc.cfg, sc.userService)) - sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*auth.FakeUserAuthTokenService) + sc.userAuthTokenService = ctxHdlr.AuthTokenService.(*authtest.FakeUserAuthTokenService) sc.jwtAuthService = ctxHdlr.JWTAuthService.(*models.FakeJWTService) sc.remoteCacheService = ctxHdlr.RemoteCache @@ -856,7 +857,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc, cbs ...func( func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.FakeDB, loginService *loginservice.LoginServiceMock, apiKeyService *apikeytest.Service, userService *usertest.FakeUserService, orgService *orgtest.FakeOrgService, - oauthTokenService *auth.FakeOAuthTokenService, + oauthTokenService *authtest.FakeOAuthTokenService, ) *contexthandler.ContextHandler { t.Helper() @@ -868,7 +869,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, mockSQLStore *dbtest.Fake } remoteCacheSvc := remotecache.NewFakeStore(t) - userAuthTokenSvc := auth.NewFakeUserAuthTokenService() + userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} authJWTSvc := models.NewFakeJWTService() tracer := tracing.InitializeTracerForTest() diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index 8d1460983df..810f04e56a1 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/user" ) @@ -48,8 +48,8 @@ func TestOrgRedirectMiddleware(t *testing.T) { middlewareScenario(t, tc.desc, func(t *testing.T, sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 1, UserID: 12} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 0, UnhashedToken: "", }, nil @@ -68,8 +68,8 @@ func TestOrgRedirectMiddleware(t *testing.T) { sc.userService.ExpectedSetUsingOrgError = fmt.Errorf("") sc.userService.ExpectedSignedInUser = &user.SignedInUser{OrgID: 1, UserID: 12} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 12, UnhashedToken: "", }, nil diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 446b7842933..156a626b10e 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -55,8 +55,8 @@ func TestMiddlewareQuota(t *testing.T) { setUp := func(sc *scenarioContext) { sc.withTokenSessionCookie("token") sc.userService.ExpectedSignedInUser = &user.SignedInUser{UserID: 12} - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 12, UnhashedToken: "", }, nil diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 866eeaa490f..1a8fe8537c5 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -65,7 +65,7 @@ func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) { sc.m.Use(AddDefaultResponseHeaders(cfg)) sc.m.UseMiddleware(web.Renderer(viewsPath, "[[", "]]")) - sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() + sc.userAuthTokenService = authtest.NewFakeUserAuthTokenService() sc.remoteCacheService = remotecache.NewFakeStore(t) contextHandler := getContextHandler(t, nil, nil, nil, nil, nil, nil, nil) diff --git a/pkg/middleware/testing.go b/pkg/middleware/testing.go index a091f9118fe..7e858cfd845 100644 --- a/pkg/middleware/testing.go +++ b/pkg/middleware/testing.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/apikey/apikeytest" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/login/loginservice" @@ -36,7 +36,7 @@ type scenarioContext struct { handlerFunc handlerFunc defaultHandler web.Handler url string - userAuthTokenService *auth.FakeUserAuthTokenService + userAuthTokenService *authtest.FakeUserAuthTokenService jwtAuthService *models.FakeJWTService remoteCacheService *remotecache.RemoteCache cfg *setting.Cfg @@ -46,7 +46,7 @@ type scenarioContext struct { loginService *loginservice.LoginServiceMock apiKeyService *apikeytest.Service userService *usertest.FakeUserService - oauthTokenService *auth.FakeOAuthTokenService + oauthTokenService *authtest.FakeOAuthTokenService orgService *orgtest.FakeOrgService req *http.Request diff --git a/pkg/models/context.go b/pkg/models/context.go index dd07b7236b8..432dee40e99 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -15,7 +16,7 @@ import ( type ReqContext struct { *web.Context *user.SignedInUser - UserToken *UserToken + UserToken *usertoken.UserToken IsSignedIn bool IsRenderCall bool diff --git a/pkg/models/usertoken/user_token.go b/pkg/models/usertoken/user_token.go new file mode 100644 index 00000000000..beb2ac1355f --- /dev/null +++ b/pkg/models/usertoken/user_token.go @@ -0,0 +1,26 @@ +package usertoken + +type TokenRevokedError struct { + UserID int64 + TokenID int64 + MaxConcurrentSessions int64 +} + +func (e *TokenRevokedError) Error() string { return "user token revoked" } + +// UserToken represents a user token +type UserToken struct { + Id int64 + UserId int64 + AuthToken string + PrevAuthToken string + UserAgent string + ClientIp string + AuthTokenSeen bool + SeenAt int64 + RotatedAt int64 + CreatedAt int64 + UpdatedAt int64 + RevokedAt int64 + UnhashedToken string +} diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index eaaa092aa08..2582d680327 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -7,10 +7,10 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/grpcserver" @@ -38,7 +38,7 @@ import ( func ProvideBackgroundServiceRegistry( httpServer *api.HTTPServer, ng *ngalert.AlertNG, cleanup *cleanup.CleanUpService, live *live.GrafanaLive, pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, processManager *process.Manager, - rendering *rendering.RenderingService, tokenService models.UserTokenBackgroundService, tracing tracing.Tracer, + rendering *rendering.RenderingService, tokenService auth.UserTokenBackgroundService, tracing tracing.Tracer, provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, usageStats *uss.UsageStats, statsCollector *statscollector.Service, grafanaUpdateChecker *updatechecker.GrafanaService, pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 57338f5d46c..4d2802abbe8 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -49,6 +49,7 @@ import ( "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/comments" @@ -271,8 +272,8 @@ var wireBasicSet = wire.NewSet( influxdb.ProvideService, wire.Bind(new(social.Service), new(*social.SocialService)), oauthtoken.ProvideService, - auth.ProvideActiveAuthTokenService, - wire.Bind(new(auth.ActiveTokenService), new(*auth.ActiveAuthTokenService)), + authimpl.ProvideActiveAuthTokenService, + wire.Bind(new(auth.ActiveTokenService), new(*authimpl.ActiveAuthTokenService)), wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), tempo.ProvideService, loki.ProvideService, diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 5c366331875..6fae56eaaca 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service" @@ -39,9 +40,9 @@ import ( ) var wireExtsBasicSet = wire.NewSet( - auth.ProvideUserAuthTokenService, - wire.Bind(new(models.UserTokenService), new(*auth.UserAuthTokenService)), - wire.Bind(new(models.UserTokenBackgroundService), new(*auth.UserAuthTokenService)), + authimpl.ProvideUserAuthTokenService, + wire.Bind(new(auth.UserTokenService), new(*authimpl.UserAuthTokenService)), + wire.Bind(new(auth.UserTokenBackgroundService), new(*authimpl.UserAuthTokenService)), licensing.ProvideService, wire.Bind(new(models.Licensing), new(*licensing.OSSLicensingService)), setting.ProvideProvider, diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go index c0d3868e266..99b93d329dd 100644 --- a/pkg/services/accesscontrol/middleware.go +++ b/pkg/services/accesscontrol/middleware.go @@ -14,8 +14,8 @@ import ( "time" "github.com/grafana/grafana/pkg/middleware/cookies" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -41,7 +41,7 @@ func Middleware(ac AccessControl) func(web.Handler, Evaluator) web.Handler { } } - var revokedErr *models.TokenRevokedError + var revokedErr *usertoken.TokenRevokedError if errors.As(c.LookupTokenErr, &revokedErr) { unauthorized(c, revokedErr) return @@ -111,7 +111,7 @@ func unauthorized(c *models.ReqContext, err error) { "message": "Unauthorized", } - var revokedErr *models.TokenRevokedError + var revokedErr *usertoken.TokenRevokedError if errors.As(err, &revokedErr) { response["message"] = "Token revoked" response["error"] = map[string]interface{}{ diff --git a/pkg/models/user_token.go b/pkg/services/auth/auth.go similarity index 74% rename from pkg/models/user_token.go rename to pkg/services/auth/auth.go index 6c92a40d86b..77b21316707 100644 --- a/pkg/models/user_token.go +++ b/pkg/services/auth/auth.go @@ -1,19 +1,32 @@ -package models +package auth import ( "context" "errors" "net" + "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" ) +const ( + QuotaTargetSrv quota.TargetSrv = "auth" + QuotaTarget quota.Target = "session" +) + +type ActiveTokenService interface { + ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) +} + // Typed errors var ( ErrUserTokenNotFound = errors.New("user token not found") ) +type TokenRevokedError = usertoken.TokenRevokedError + // CreateTokenErr represents a token creation error; used in Enterprise type CreateTokenErr struct { StatusCode int @@ -35,30 +48,7 @@ type TokenExpiredError struct { func (e *TokenExpiredError) Error() string { return "user token expired" } -type TokenRevokedError struct { - UserID int64 - TokenID int64 - MaxConcurrentSessions int64 -} - -func (e *TokenRevokedError) Error() string { return "user token revoked" } - -// UserToken represents a user token -type UserToken struct { - Id int64 - UserId int64 - AuthToken string - PrevAuthToken string - UserAgent string - ClientIp string - AuthTokenSeen bool - SeenAt int64 - RotatedAt int64 - CreatedAt int64 - UpdatedAt int64 - RevokedAt int64 - UnhashedToken string -} +type UserToken = usertoken.UserToken type RevokeAuthTokenCmd struct { AuthTokenId int64 `json:"authTokenId"` diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/authimpl/auth_token.go similarity index 91% rename from pkg/services/auth/auth_token.go rename to pkg/services/auth/authimpl/auth_token.go index f261e33bcd1..c969686a2f8 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/authimpl/auth_token.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -42,10 +42,6 @@ type UserAuthTokenService struct { log log.Logger } -type ActiveTokenService interface { - ActiveTokenCount(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) -} - type ActiveAuthTokenService struct { cfg *setting.Cfg sqlStore db.DB @@ -63,7 +59,7 @@ func ProvideActiveAuthTokenService(cfg *setting.Cfg, sqlStore db.DB, quotaServic } if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ - TargetSrv: QuotaTargetSrv, + TargetSrv: auth.QuotaTargetSrv, DefaultLimits: defaultLimits, Reporter: s.ActiveTokenCount, }); err != nil { @@ -86,7 +82,7 @@ func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota. return err }) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + tag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) if err != nil { return nil, err } @@ -96,7 +92,7 @@ func (a *ActiveAuthTokenService) ActiveTokenCount(ctx context.Context, _ *quota. return u, err } -func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { +func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { token, err := util.RandomHex(16) if err != nil { return nil, err @@ -138,13 +134,13 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, ctxLogger := s.log.FromContext(ctx) ctxLogger.Debug("user auth token created", "tokenId", userAuthToken.Id, "userId", userAuthToken.UserId, "clientIP", userAuthToken.ClientIp, "userAgent", userAuthToken.UserAgent, "authToken", userAuthToken.AuthToken) - var userToken models.UserToken + var userToken auth.UserToken err = userAuthToken.toUserToken(&userToken) return &userToken, err } -func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*models.UserToken, error) { +func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { hashedToken := hashToken(unhashedToken) var model userAuthToken var exists bool @@ -162,14 +158,14 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st } if !exists { - return nil, models.ErrUserTokenNotFound + return nil, auth.ErrUserTokenNotFound } ctxLogger := s.log.FromContext(ctx) if model.RevokedAt > 0 { ctxLogger.Debug("user token has been revoked", "user ID", model.UserId, "token ID", model.Id) - return nil, &models.TokenRevokedError{ + return nil, &auth.TokenRevokedError{ UserID: model.UserId, TokenID: model.Id, } @@ -177,7 +173,7 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st if model.CreatedAt <= s.createdAfterParam() || model.RotatedAt <= s.rotatedAfterParam() { ctxLogger.Debug("user token has expired", "user ID", model.UserId, "token ID", model.Id) - return nil, &models.TokenExpiredError{ + return nil, &auth.TokenExpiredError{ UserID: model.UserId, TokenID: model.Id, } @@ -242,13 +238,13 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st model.UnhashedToken = unhashedToken - var userToken models.UserToken + var userToken auth.UserToken err = model.toUserToken(&userToken) return &userToken, err } -func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models.UserToken, +func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { if token == nil { return false, nil @@ -328,9 +324,9 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models return false, nil } -func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error { +func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.UserToken, soft bool) error { if token == nil { - return models.ErrUserTokenNotFound + return auth.ErrUserTokenNotFound } model, err := userAuthTokenFromUserToken(token) @@ -361,7 +357,7 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us if rowsAffected == 0 { ctxLogger.Debug("user auth token not found/revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) - return models.ErrUserTokenNotFound + return auth.ErrUserTokenNotFound } ctxLogger.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "soft", soft) @@ -418,8 +414,8 @@ func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, use }) } -func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { - var result models.UserToken +func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { + var result auth.UserToken err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { var token userAuthToken exists, err := dbSession.Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token) @@ -428,7 +424,7 @@ func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTok } if !exists { - return models.ErrUserTokenNotFound + return auth.ErrUserTokenNotFound } return token.toUserToken(&result) @@ -437,8 +433,8 @@ func (s *UserAuthTokenService) GetUserToken(ctx context.Context, userId, userTok return &result, err } -func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { - result := []*models.UserToken{} +func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { + result := []*auth.UserToken{} err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { var tokens []*userAuthToken err := dbSession.Where("user_id = ? AND created_at > ? AND rotated_at > ? AND revoked_at = 0", @@ -451,7 +447,7 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) } for _, token := range tokens { - var userToken models.UserToken + var userToken auth.UserToken if err := token.toUserToken(&userToken); err != nil { return err } @@ -464,8 +460,8 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) return result, err } -func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { - result := []*models.UserToken{} +func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { + result := []*auth.UserToken{} err := s.SQLStore.WithDbSession(ctx, func(dbSession *db.Session) error { var tokens []*userAuthToken err := dbSession.Where("user_id = ? AND revoked_at > 0", userId).Find(&tokens) @@ -474,7 +470,7 @@ func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId } for _, token := range tokens { - var userToken models.UserToken + var userToken auth.UserToken if err := token.toUserToken(&userToken); err != nil { return err } @@ -507,7 +503,7 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { return limits, nil } - globalQuotaTag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + globalQuotaTag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) if err != nil { return limits, err } diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/authimpl/auth_token_test.go similarity index 96% rename from pkg/services/auth/auth_token_test.go rename to pkg/services/auth/authimpl/auth_token_test.go index 16886d7b439..97528bf6be2 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/authimpl/auth_token_test.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" @@ -8,12 +8,12 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/services/auth" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -29,7 +29,7 @@ func TestUserAuthToken(t *testing.T) { defer func() { getTime = time.Now }() t.Run("When creating token", func(t *testing.T) { - createToken := func() *models.UserToken { + createToken := func() *auth.UserToken { userToken, err := ctx.tokenService.CreateToken(context.Background(), user, net.ParseIP("192.168.10.11"), "some user agent") require.Nil(t, err) @@ -43,7 +43,7 @@ func TestUserAuthToken(t *testing.T) { t.Run("Can count active tokens", func(t *testing.T) { m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + tag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) require.NoError(t, err) count, ok := m.Get(tag) require.True(t, ok) @@ -65,7 +65,7 @@ func TestUserAuthToken(t *testing.T) { t.Run("When lookup hashed token should return user auth token not found error", func(t *testing.T) { userToken, err := ctx.tokenService.LookupToken(context.Background(), userToken.AuthToken) - require.Equal(t, models.ErrUserTokenNotFound, err) + require.Equal(t, auth.ErrUserTokenNotFound, err) require.Nil(t, userToken) }) @@ -90,13 +90,13 @@ func TestUserAuthToken(t *testing.T) { t.Run("revoking nil token should return error", func(t *testing.T) { err := ctx.tokenService.RevokeToken(context.Background(), nil, false) - require.Equal(t, models.ErrUserTokenNotFound, err) + require.Equal(t, auth.ErrUserTokenNotFound, err) }) t.Run("revoking non-existing token should return error", func(t *testing.T) { userToken.Id = 1000 err := ctx.tokenService.RevokeToken(context.Background(), userToken, false) - require.Equal(t, models.ErrUserTokenNotFound, err) + require.Equal(t, auth.ErrUserTokenNotFound, err) }) ctx = createTestContext(t) @@ -209,13 +209,13 @@ func TestUserAuthToken(t *testing.T) { } notGood, err := ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken) - require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&models.TokenExpiredError{})) + require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&auth.TokenExpiredError{})) require.Nil(t, notGood) t.Run("should not find active token when expired", func(t *testing.T) { m, err := ctx.activeTokenService.ActiveTokenCount(context.Background(), "a.ScopeParameters{}) require.Nil(t, err) - tag, err := quota.NewTag(QuotaTargetSrv, QuotaTarget, quota.GlobalScope) + tag, err := quota.NewTag(auth.QuotaTargetSrv, auth.QuotaTarget, quota.GlobalScope) require.NoError(t, err) count, ok := m.Get(tag) require.True(t, ok) @@ -247,7 +247,7 @@ func TestUserAuthToken(t *testing.T) { } notGood, err := ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken) - require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&models.TokenExpiredError{})) + require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(&auth.TokenExpiredError{})) require.Nil(t, notGood) }) }) @@ -274,7 +274,7 @@ func TestUserAuthToken(t *testing.T) { model, err := ctx.getAuthTokenByID(userToken.Id) require.Nil(t, err) - var tok models.UserToken + var tok auth.UserToken err = model.toUserToken(&tok) require.Nil(t, err) @@ -471,7 +471,7 @@ func TestUserAuthToken(t *testing.T) { }) t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) { - ut := models.UserToken{ + ut := auth.UserToken{ Id: 1, UserId: 2, AuthToken: "a", @@ -524,7 +524,7 @@ func TestUserAuthToken(t *testing.T) { require.Nil(t, err) uatMap := uatJSON.MustMap() - var ut models.UserToken + var ut auth.UserToken err = uat.toUserToken(&ut) require.Nil(t, err) utBytes, err := json.Marshal(ut) diff --git a/pkg/services/auth/model.go b/pkg/services/auth/authimpl/model.go similarity index 77% rename from pkg/services/auth/model.go rename to pkg/services/auth/authimpl/model.go index afc5b566c48..407927df572 100644 --- a/pkg/services/auth/model.go +++ b/pkg/services/auth/authimpl/model.go @@ -1,10 +1,9 @@ -package auth +package authimpl import ( "fmt" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/auth" ) type userAuthToken struct { @@ -23,13 +22,13 @@ type userAuthToken struct { UnhashedToken string `xorm:"-"` } -func userAuthTokenFromUserToken(ut *models.UserToken) (*userAuthToken, error) { +func userAuthTokenFromUserToken(ut *auth.UserToken) (*userAuthToken, error) { var uat userAuthToken err := uat.fromUserToken(ut) return &uat, err } -func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error { +func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) error { if uat == nil { return fmt.Errorf("needs pointer to userAuthToken struct") } @@ -51,7 +50,7 @@ func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error { return nil } -func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { +func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error { if uat == nil { return fmt.Errorf("needs pointer to userAuthToken struct") } @@ -72,8 +71,3 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error { return nil } - -const ( - QuotaTargetSrv quota.TargetSrv = "auth" - QuotaTarget quota.Target = "session" -) diff --git a/pkg/services/auth/token_cleanup.go b/pkg/services/auth/authimpl/token_cleanup.go similarity index 99% rename from pkg/services/auth/token_cleanup.go rename to pkg/services/auth/authimpl/token_cleanup.go index a82f13630fe..08d8ae7c614 100644 --- a/pkg/services/auth/token_cleanup.go +++ b/pkg/services/auth/authimpl/token_cleanup.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" diff --git a/pkg/services/auth/token_cleanup_test.go b/pkg/services/auth/authimpl/token_cleanup_test.go similarity index 99% rename from pkg/services/auth/token_cleanup_test.go rename to pkg/services/auth/authimpl/token_cleanup_test.go index a39e0d7892b..e207448c8f6 100644 --- a/pkg/services/auth/token_cleanup_test.go +++ b/pkg/services/auth/authimpl/token_cleanup_test.go @@ -1,4 +1,4 @@ -package auth +package authimpl import ( "context" diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/authtest/testing.go similarity index 77% rename from pkg/services/auth/testing.go rename to pkg/services/auth/authtest/testing.go index 63b08a9a639..d2bbd09b46b 100644 --- a/pkg/services/auth/testing.go +++ b/pkg/services/auth/authtest/testing.go @@ -1,4 +1,4 @@ -package auth +package authtest import ( "context" @@ -6,42 +6,43 @@ import ( "time" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/user" "golang.org/x/oauth2" ) type FakeUserAuthTokenService struct { - CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) - TryRotateTokenProvider func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error) - LookupTokenProvider func(ctx context.Context, unhashedToken string) (*models.UserToken, error) - RevokeTokenProvider func(ctx context.Context, token *models.UserToken, soft bool) error + CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) + TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) + LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) + RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error RevokeAllUserTokensProvider func(ctx context.Context, userId int64) error ActiveAuthTokenCount func(ctx context.Context) (int64, error) - GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) - GetUserTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) - GetUserRevokedTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error) + GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) + GetUserTokensProvider func(ctx context.Context, userId int64) ([]*auth.UserToken, error) + GetUserRevokedTokensProvider func(ctx context.Context, userId int64) ([]*auth.UserToken, error) BatchRevokedTokenProvider func(ctx context.Context, userIds []int64) error } func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { return &FakeUserAuthTokenService{ - CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { - return &models.UserToken{ + CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 0, UnhashedToken: "", }, nil }, - TryRotateTokenProvider: func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error) { + TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { return false, nil }, - LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { - return &models.UserToken{ + LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { + return &auth.UserToken{ UserId: 0, UnhashedToken: "", }, nil }, - RevokeTokenProvider: func(ctx context.Context, token *models.UserToken, soft bool) error { + RevokeTokenProvider: func(ctx context.Context, token *auth.UserToken, soft bool) error { return nil }, RevokeAllUserTokensProvider: func(ctx context.Context, userId int64) error { @@ -53,10 +54,10 @@ func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { ActiveAuthTokenCount: func(ctx context.Context) (int64, error) { return 10, nil }, - GetUserTokenProvider: func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { + GetUserTokenProvider: func(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { return nil, nil }, - GetUserTokensProvider: func(ctx context.Context, userId int64) ([]*models.UserToken, error) { + GetUserTokensProvider: func(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return nil, nil }, } @@ -68,20 +69,20 @@ func (s *FakeUserAuthTokenService) Init() error { return nil } -func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*models.UserToken, error) { +func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { return s.CreateTokenProvider(context.Background(), user, clientIP, userAgent) } -func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*models.UserToken, error) { +func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { return s.LookupTokenProvider(context.Background(), unhashedToken) } -func (s *FakeUserAuthTokenService) TryRotateToken(ctx context.Context, token *models.UserToken, clientIP net.IP, +func (s *FakeUserAuthTokenService) TryRotateToken(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { return s.TryRotateTokenProvider(context.Background(), token, clientIP, userAgent) } -func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error { +func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *auth.UserToken, soft bool) error { return s.RevokeTokenProvider(context.Background(), token, soft) } @@ -93,15 +94,15 @@ func (s *FakeUserAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, return s.ActiveAuthTokenCount(context.Background()) } -func (s *FakeUserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error) { +func (s *FakeUserAuthTokenService) GetUserToken(ctx context.Context, userId, userTokenId int64) (*auth.UserToken, error) { return s.GetUserTokenProvider(context.Background(), userId, userTokenId) } -func (s *FakeUserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { +func (s *FakeUserAuthTokenService) GetUserTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return s.GetUserTokensProvider(context.Background(), userId) } -func (s *FakeUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) { +func (s *FakeUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*auth.UserToken, error) { return s.GetUserRevokedTokensProvider(context.Background(), userId) } diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 307ca2b51bc..dc697eff484 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/org/orgtest" @@ -80,7 +80,7 @@ func getContextHandler(t *testing.T) *ContextHandler { cfg.AuthProxyHeaderProperty = "username" remoteCacheSvc, err := remotecache.ProvideService(cfg, sqlStore) require.NoError(t, err) - userAuthTokenSvc := auth.NewFakeUserAuthTokenService() + userAuthTokenSvc := authtest.NewFakeUserAuthTokenService() renderSvc := &fakeRenderService{} authJWTSvc := models.NewFakeJWTService() tracer := tracing.InitializeTracerForTest() diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index c8d67f165c0..6e2be2233c5 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -44,7 +44,7 @@ const ( const ServiceName = "ContextHandler" -func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtService models.JWTService, +func ProvideService(cfg *setting.Cfg, tokenService auth.UserTokenService, jwtService models.JWTService, remoteCache *remotecache.RemoteCache, renderService rendering.Service, sqlStore db.DB, tracer tracing.Tracer, authProxy *authproxy.AuthProxy, loginService login.Service, apiKeyService apikey.Service, authenticator loginpkg.Authenticator, userService user.Service, @@ -77,7 +77,7 @@ func ProvideService(cfg *setting.Cfg, tokenService models.UserTokenService, jwtS // ContextHandler is a middleware. type ContextHandler struct { Cfg *setting.Cfg - AuthTokenService models.UserTokenService + AuthTokenService auth.UserTokenService JWTAuthService models.JWTService RemoteCache *remotecache.RemoteCache RenderService rendering.Service @@ -474,7 +474,7 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org } err = h.AuthTokenService.RevokeToken(ctx, token, false) - if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) { + if err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) { reqContext.Logger.Error("failed to revoke auth token", "error", err) } return false @@ -506,8 +506,8 @@ func (h *ContextHandler) deleteInvalidCookieEndOfRequestFunc(reqContext *models. } } -func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext, authTokenService models.UserTokenService, - token *models.UserToken) web.BeforeFunc { +func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext, authTokenService auth.UserTokenService, + token *auth.UserToken) web.BeforeFunc { return func(w web.ResponseWriter) { // if response has already been written, skip. if w.Written() { diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 3e61b65d0ed..fa882f2fc81 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -7,14 +7,16 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestDontRotateTokensOnCancelledRequests(t *testing.T) { @@ -25,15 +27,15 @@ func TestDontRotateTokensOnCancelledRequests(t *testing.T) { require.NoError(t, err) tryRotateCallCount := 0 - uts := &auth.FakeUserAuthTokenService{ - TryRotateTokenProvider: func(ctx context.Context, token *models.UserToken, clientIP net.IP, + uts := &authtest.FakeUserAuthTokenService{ + TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { tryRotateCallCount++ return false, nil }, } - token := &models.UserToken{AuthToken: "oldtoken"} + token := &auth.UserToken{AuthToken: "oldtoken"} fn := ctxHdlr.rotateEndOfRequestFunc(reqContext, uts, token) cancel() @@ -48,8 +50,8 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { reqContext, rr, err := initTokenRotationScenario(context.Background(), t, ctxHdlr) require.NoError(t, err) - uts := &auth.FakeUserAuthTokenService{ - TryRotateTokenProvider: func(ctx context.Context, token *models.UserToken, clientIP net.IP, + uts := &authtest.FakeUserAuthTokenService{ + TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) { newToken, err := util.RandomHex(16) require.NoError(t, err) @@ -58,7 +60,7 @@ func TestTokenRotationAtEndOfRequest(t *testing.T) { }, } - token := &models.UserToken{AuthToken: "oldtoken"} + token := &auth.UserToken{AuthToken: "oldtoken"} ctxHdlr.rotateEndOfRequestFunc(reqContext, uts, token)(reqContext.Resp) diff --git a/pkg/services/ngalert/api/util_test.go b/pkg/services/ngalert/api/util_test.go index 34f4deb08e2..3ed32d797e6 100644 --- a/pkg/services/ngalert/api/util_test.go +++ b/pkg/services/ngalert/api/util_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/auth" models2 "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" @@ -45,7 +46,7 @@ func TestAlertingProxy_createProxyContext(t *testing.T) { Req: &http.Request{}, }, SignedInUser: &user.SignedInUser{}, - UserToken: &models.UserToken{}, + UserToken: &auth.UserToken{}, IsSignedIn: rand.Int63()%2 == 1, IsRenderCall: rand.Int63()%2 == 1, AllowAnonymous: rand.Int63()%2 == 1, diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 17164adc785..5d73021f2c9 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/dashboards" dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/datasources" @@ -464,7 +465,7 @@ func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quot func setupEnv(t *testing.T, sqlStore *sqlstore.SQLStore, b bus.Bus, quotaService quota.Service) { _, err := apikeyimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) require.NoError(t, err) - _, err = auth.ProvideActiveAuthTokenService(sqlStore.Cfg, sqlStore, quotaService) + _, err = authimpl.ProvideActiveAuthTokenService(sqlStore.Cfg, sqlStore, quotaService) require.NoError(t, err) _, err = dashboardStore.ProvideDashboardStore(sqlStore, sqlStore.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) require.NoError(t, err) From 48c34d310c244be32b56d3cd6db530ea390ce726 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Fri, 18 Nov 2022 09:04:43 +0000 Subject: [PATCH 295/926] Alerting: Add tests that check current No Data behaviour with two conditions (#58650) --- pkg/expr/classic/classic_test.go | 125 +++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index e5d85f9dd78..74f6a09eda2 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -473,6 +473,131 @@ func TestConditionsCmd(t *testing.T) { }) return newResults(v) }, + }, { + name: "two queries with two conditions using and operator and first is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(ptr.Float64(0)) + v.SetMeta([]EvalMatch{{Metric: "NoData"}, {Value: ptr.Float64(5)}}) + return newResults(v) + }, + }, { + // TODO: NoData behavior is different if the last condition is no data + name: "two queries with two conditions using and operator and last is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "and", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}, {Metric: "NoData"}}) + return newResults(v) + }, + }, { + name: "two queries with two conditions using or operator and first is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Metric: "NoData"}, {Value: ptr.Float64(5)}}) + return newResults(v) + }, + }, { + name: "two queries with two conditions using or operator and last is No Data", + vars: mathexp.Vars{ + "A": mathexp.Results{ + Values: []mathexp.Value{newSeries(ptr.Float64(5))}, + }, + "B": mathexp.Results{ + Values: []mathexp.Value{mathexp.NoData{}.New()}, + }, + }, + cmd: &ConditionsCmd{ + Conditions: []condition{ + { + InputRefID: "A", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + { + InputRefID: "B", + Reducer: reducer("min"), + Operator: "or", + Evaluator: &thresholdEvaluator{"gt", 1}, + }, + }, + }, + expected: func() mathexp.Results { + v := newNumber(nil) + v.SetMeta([]EvalMatch{{Value: ptr.Float64(5)}, {Metric: "NoData"}}) + return newResults(v) + }, }} for _, tt := range tests { From d46e3916a137aed51c917c50c54b8fb969b829e0 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 18 Nov 2022 09:05:45 +0000 Subject: [PATCH 296/926] Navigation: move connections + integrations to be a top level item (#58902) * move connections + integrations to be a top level item * add a test to check we can move apps to the root * split out movePlugin logic into a separate function * fix linting * rename movePlugin -> addPluginToSection --- pkg/services/navtree/models.go | 1 + pkg/services/navtree/navtreeimpl/applinks.go | 13 +++++++--- .../navtree/navtreeimpl/applinks_test.go | 24 +++++++++++++++++++ .../app/core/components/MegaMenu/MegaMenu.tsx | 7 ++---- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index f71ceea0808..8f521be5a46 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -36,6 +36,7 @@ const ( ) const ( + NavIDRoot = "root" NavIDDashboards = "dashboards" NavIDDashboardsBrowse = "dashboards/browse" NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 22c0aef5acf..e59a4d6bd09 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -170,6 +170,12 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo } appLink.Children = childrenWithoutDefault + s.addPluginToSection(c, treeRoot, plugin, appLink) + + return nil +} + +func (s *ServiceImpl) addPluginToSection(c *models.ReqContext, treeRoot *navtree.NavTreeRoot, plugin plugins.PluginDTO, appLink *navtree.NavLink) { // Handle moving apps into specific navtree sections alertingNode := treeRoot.FindById(navtree.NavIDAlerting) sectionID := navtree.NavIDApps @@ -183,7 +189,9 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo } } - if navNode := treeRoot.FindById(sectionID); navNode != nil { + if sectionID == navtree.NavIDRoot { + treeRoot.AddSection(appLink) + } else if navNode := treeRoot.FindById(sectionID); navNode != nil { navNode.Children = append(navNode.Children, appLink) } else { switch sectionID { @@ -227,8 +235,6 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqCo s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", sectionID) } } - - return nil } func (s *ServiceImpl) hasAccessToInclude(c *models.ReqContext, pluginID string) func(include *plugins.Includes) bool { @@ -256,6 +262,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incident"}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, + "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightSavedItems + 1, Text: "Connections"}, } s.navigationAppPathConfig = map[string]NavigationAppConfig{ diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go index de6ffacf500..f0ed89967ba 100644 --- a/pkg/services/navtree/navtreeimpl/applinks_test.go +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -147,6 +147,30 @@ func TestAddAppLinks(t *testing.T) { require.Equal(t, "Page2", app1Node.Children[0].Text) }) + // This can be done by using `[navigation.app_sections]` in the INI config + t.Run("Should move apps that have root nav id configured to the root", func(t *testing.T) { + service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) + service.navigationAppConfig = map[string]NavigationAppConfig{ + "test-app1": {SectionID: navtree.NavIDRoot}, + } + + treeRoot := navtree.NavTreeRoot{} + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + + // Check if the plugin gets moved to the root + require.Len(t, treeRoot.Children, 2) + require.Equal(t, "plugin-page-test-app1", treeRoot.Children[0].Id) + + // Check if it is not under the "Apps" section anymore + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.NotNil(t, appsNode) + require.Len(t, appsNode.Children, 2) + require.Equal(t, "plugin-page-test-app2", appsNode.Children[0].Id) + require.Equal(t, "plugin-page-test-app3", appsNode.Children[1].Id) + }) + // This can be done by using `[navigation.app_sections]` in the INI config t.Run("Should move apps that have specific nav id configured to correct section", func(t *testing.T) { service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav) diff --git a/public/app/core/components/MegaMenu/MegaMenu.tsx b/public/app/core/components/MegaMenu/MegaMenu.tsx index a6115e7988f..b9a4cef37c7 100644 --- a/public/app/core/components/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/MegaMenu/MegaMenu.tsx @@ -25,17 +25,14 @@ export const MegaMenu = React.memo(({ onClose, searchBarHidden }) => { const navTree = cloneDeep(navBarTree); const coreItems = navTree - .filter((item) => item.section === NavSection.Core) - .map((item) => enrichWithInteractionTracking(item, true)); - const pluginItems = navTree - .filter((item) => item.section === NavSection.Plugin) + .filter((item) => item.section === NavSection.Core || item.section === NavSection.Plugin) .map((item) => enrichWithInteractionTracking(item, true)); const configItems = enrichConfigItems( navTree.filter((item) => item.section === NavSection.Config && item && item.id !== 'help' && item.id !== 'profile'), location ).map((item) => enrichWithInteractionTracking(item, true)); - const navItems = [...coreItems, ...pluginItems, ...configItems]; + const navItems = [...coreItems, ...configItems]; const activeItem = getActiveItem(navItems, location.pathname); From 9c98314e9f9f488470eea88d6f596d7d4cf9ade3 Mon Sep 17 00:00:00 2001 From: Misi Date: Fri, 18 Nov 2022 10:12:17 +0100 Subject: [PATCH 297/926] OAuth: Refactor OAuth parameters handling to support obtaining refresh tokens for Google OAuth (#58782) * Add ApprovalForce to AuthCodeOptions * Extract access token validity check to a function * Refactor * Oauth: set options internally instead of exposing new function * Align tests * Remove unused function Co-authored-by: Karl Persson --- pkg/api/frontendsettings_test.go | 2 +- pkg/api/login_oauth.go | 4 +-- pkg/api/login_oauth_test.go | 8 +++--- pkg/login/social/azuread_oauth_test.go | 28 ++++++++++--------- pkg/login/social/generic_oauth.go | 9 ++++++ pkg/login/social/github_oauth_test.go | 4 ++- pkg/login/social/google_oauth.go | 9 ++++++ pkg/login/social/social.go | 21 ++++++++------ pkg/server/server.go | 2 +- pkg/services/contexthandler/contexthandler.go | 23 +++++++++------ 10 files changed, 70 insertions(+), 40 deletions(-) diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index ef37a076523..dc1d1446a43 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -58,7 +58,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. grafanaUpdateChecker: &updatechecker.GrafanaService{}, AccessControl: accesscontrolmock.New().WithDisabled(), PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), - SocialService: social.ProvideService(cfg), + SocialService: social.ProvideService(cfg, features), } m := web.New() diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 88a465d7fdb..603f158611a 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -97,9 +97,7 @@ func (hs *HTTPServer) OAuthLogin(ctx *models.ReqContext) { code := ctx.Query("code") if code == "" { - // FIXME: access_type is a Google OAuth2 specific thing, consider refactoring this and moving to google_oauth.go - opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} - + var opts []oauth2.AuthCodeOption if provider.UsePKCE { ascii, pkce, err := genPKCECode() if err != nil { diff --git a/pkg/api/login_oauth_test.go b/pkg/api/login_oauth_test.go index b5143949443..f39c5cda96c 100644 --- a/pkg/api/login_oauth_test.go +++ b/pkg/api/login_oauth_test.go @@ -9,15 +9,15 @@ import ( "path/filepath" "testing" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/secrets/fakes" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/login/social" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/licensing" + "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -36,7 +36,7 @@ func setupOAuthTest(t *testing.T, cfg *setting.Cfg) *web.Mux { Cfg: cfg, License: &licensing.OSSLicensingService{Cfg: cfg}, SQLStore: sqlStore, - SocialService: social.ProvideService(cfg), + SocialService: social.ProvideService(cfg, featuremgmt.WithFeatures()), HooksService: hooks.ProvideService(), SecretsService: fakes.NewFakeSecretsService(), } diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index ea632e9457b..4a03dc12002 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func trueBoolPtr() *bool { @@ -54,7 +56,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), }, want: &BasicUserInfo{ Id: "1234", @@ -93,7 +95,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), }, want: &BasicUserInfo{ Id: "1234", @@ -143,7 +145,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Only other roles", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -171,7 +173,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor", false, *featuremgmt.WithFeatures()), }, want: &BasicUserInfo{ Id: "1234", @@ -220,7 +222,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { }, { name: "Grafana Admin but setting is disabled", - fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false)}, + fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor", false, *featuremgmt.WithFeatures())}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -242,7 +244,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { name: "Editor roles in claim and GrafanaAdminAssignment enabled", fields: fields{ SocialBase: newSocialBase("azuread", - &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false)}, + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -263,7 +265,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Grafana Admin and Editor roles in claim", fields: fields{SocialBase: newSocialBase("azuread", - &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false)}, + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "", false, *featuremgmt.WithFeatures())}, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -302,7 +304,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { fields: fields{ allowedGroups: []string{"foo", "bar"}, SocialBase: newSocialBase("azuread", - &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false), + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -324,7 +326,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when ClaimsNames and ClaimsSources is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ ID: "1", @@ -349,7 +351,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch groups when forceUseGraphAPI is set", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()), forceUseGraphAPI: true, }, claims: &azureClaims{ @@ -376,7 +378,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no match", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -392,7 +394,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no role claims returned", fields: fields{ - SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false), + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, "", false, *featuremgmt.WithFeatures()), }, claims: &azureClaims{ Email: "me@example.com", @@ -416,7 +418,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { } if tt.fields.SocialBase == nil { - s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false) + s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures()) } key := []byte("secret") diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 69a47ccef22..d0cd1bc716d 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -14,6 +14,8 @@ import ( "strconv" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) type SocialGenericOAuth struct { @@ -504,3 +506,10 @@ func (s *SocialGenericOAuth) FetchOrganizations(client *http.Client) ([]string, return logins, true } + +func (s *SocialGenericOAuth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { + opts = append(opts, oauth2.AccessTypeOffline) + } + return s.SocialBase.AuthCodeURL(state, opts...) +} diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go index f610bd5843c..cdb400b15ea 100644 --- a/pkg/login/social/github_oauth_test.go +++ b/pkg/login/social/github_oauth_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) const testGHUserTeamsJSON = `[ @@ -202,7 +204,7 @@ func TestSocialGitHub_UserInfo(t *testing.T) { s := &SocialGithub{ SocialBase: newSocialBase("github", &oauth2.Config{}, - &OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false), + &OAuthInfo{RoleAttributePath: tt.roleAttributePath}, tt.autoAssignOrgRole, false, *featuremgmt.WithFeatures()), allowedOrganizations: []string{}, apiUrl: server.URL + "/user", teamIds: []int{}, diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go index 0c0a1d256dd..b499cc613be 100644 --- a/pkg/login/social/google_oauth.go +++ b/pkg/login/social/google_oauth.go @@ -6,6 +6,8 @@ import ( "net/http" "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/services/featuremgmt" ) type SocialGoogle struct { @@ -38,3 +40,10 @@ func (s *SocialGoogle) UserInfo(client *http.Client, token *oauth2.Token) (*Basi Login: data.Email, }, nil } + +func (s *SocialGoogle) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + if s.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { + opts = append(opts, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + } + return s.SocialBase.AuthCodeURL(state, opts...) +} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index 217f2606a19..fad3ed7095b 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -16,6 +16,7 @@ import ( "golang.org/x/text/language" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -58,7 +59,7 @@ type OAuthInfo struct { UsePKCE bool } -func ProvideService(cfg *setting.Cfg) *SocialService { +func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *SocialService { ss := SocialService{ cfg: cfg, oAuthProvider: make(map[string]*OAuthInfo), @@ -139,7 +140,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // GitHub. if name == "github" { ss.socialMap["github"] = &SocialGithub{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), @@ -149,7 +150,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // GitLab. if name == "gitlab" { ss.socialMap["gitlab"] = &SocialGitlab{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } @@ -158,7 +159,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Google. if name == "google" { ss.socialMap["google"] = &SocialGoogle{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), hostedDomain: info.HostedDomain, apiUrl: info.ApiUrl, } @@ -167,7 +168,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // AzureAD. if name == "azuread" { ss.socialMap["azuread"] = &SocialAzureAD{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false), } @@ -176,7 +177,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Okta if name == "okta" { ss.socialMap["okta"] = &SocialOkta{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } @@ -185,7 +186,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // Generic - Uses the same scheme as GitHub. if name == "generic_oauth" { ss.socialMap["generic_oauth"] = &SocialGenericOAuth{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), apiUrl: info.ApiUrl, teamsUrl: info.TeamsUrl, emailAttributeName: info.EmailAttributeName, @@ -214,8 +215,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService { } ss.socialMap[grafanaCom] = &SocialGrafanaCom{ - SocialBase: newSocialBase(name, &config, info, - cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync), + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features), url: cfg.GrafanaComURL, allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), } @@ -261,6 +261,7 @@ type SocialBase struct { roleAttributeStrict bool autoAssignOrgRole string skipOrgRoleSync bool + features featuremgmt.FeatureManager } type Error struct { @@ -295,6 +296,7 @@ func newSocialBase(name string, info *OAuthInfo, autoAssignOrgRole string, skipOrgRoleSync bool, + features featuremgmt.FeatureManager, ) *SocialBase { logger := log.New("oauth." + name) @@ -308,6 +310,7 @@ func newSocialBase(name string, roleAttributePath: info.RoleAttributePath, roleAttributeStrict: info.RoleAttributeStrict, skipOrgRoleSync: skipOrgRoleSync, + features: features, } } diff --git a/pkg/server/server.go b/pkg/server/server.go index aec724df979..4b80055b009 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -127,7 +127,7 @@ func (s *Server) init() error { } login.ProvideService(s.HTTPServer.SQLStore, s.HTTPServer.Login, s.loginAttemptService, s.userService) - social.ProvideService(s.cfg) + social.ProvideService(s.cfg, s.HTTPServer.Features) if err := s.roleRegistry.RegisterFixedRoles(s.context); err != nil { return err diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 6e2be2233c5..01e48445926 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -449,20 +449,14 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org return false } - getTime := h.GetTime - if getTime == nil { - getTime = time.Now - } - if h.features.IsEnabled(featuremgmt.FlagAccessTokenExpirationCheck) { // Check whether the logged in User has a token (whether the User used an OAuth provider to login) oauthToken, exists, _ := h.oauthTokenService.HasOAuthEntry(ctx, queryResult) if exists { - // Skip where the OAuthExpiry is default/zero/unset - if !oauthToken.OAuthExpiry.IsZero() && oauthToken.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) { + if h.hasAccessTokenExpired(oauthToken) { reqContext.Logger.Info("access token expired", "userId", query.UserID, "expiry", fmt.Sprintf("%v", oauthToken.OAuthExpiry)) - // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and Invalidate the OAuth tokens + // If the User doesn't have a refresh_token or refreshing the token was unsuccessful then log out the User and invalidate the OAuth tokens if err = h.oauthTokenService.TryTokenRefresh(ctx, oauthToken); err != nil { if !errors.Is(err, oauthtoken.ErrNoRefreshTokenFound) { reqContext.Logger.Error("could not fetch a new access token", "userId", oauthToken.UserId, "error", err) @@ -732,3 +726,16 @@ func AuthHTTPHeaderListFromContext(c context.Context) *AuthHTTPHeaderList { } return nil } + +func (h *ContextHandler) hasAccessTokenExpired(token *models.UserAuth) bool { + if token.OAuthExpiry.IsZero() { + return false + } + + getTime := h.GetTime + if getTime == nil { + getTime = time.Now + } + + return token.OAuthExpiry.Round(0).Add(-oauthtoken.ExpiryDelta).Before(getTime()) +} From b77c3946a5b2dac7ded143b8caddfc33852182ef Mon Sep 17 00:00:00 2001 From: George Robinson Date: Fri, 18 Nov 2022 09:28:21 +0000 Subject: [PATCH 298/926] Alerting: Fix ConditionsCmd No Data for "has no value" (#58634) This commit fixes a bug where ConditionsCmd returns No Data even when the condition checks for "has no value". It should return 1 with a nil match. --- pkg/expr/classic/classic.go | 12 ++++++++---- pkg/expr/classic/classic_test.go | 13 ++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/expr/classic/classic.go b/pkg/expr/classic/classic.go index 4b06f0ce65b..33e57d5e6a5 100644 --- a/pkg/expr/classic/classic.go +++ b/pkg/expr/classic/classic.go @@ -79,6 +79,12 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V querySeriesSet := vars[c.InputRefID] nilReducedCount := 0 firingCount := 0 + + if len(querySeriesSet.Values) == 0 { + // Append a NoData data frame so "has no value" still works + querySeriesSet.Values = append(querySeriesSet.Values, mathexp.NoData{}.New()) + } + for _, val := range querySeriesSet.Values { var reducedNum mathexp.Number var name string @@ -103,10 +109,6 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V // TODO handle error / no data signals thisCondNoDataFound := reducedNum.GetFloat64Value() == nil - if thisCondNoDataFound { - nilReducedCount++ - } - evalRes := c.Evaluator.Eval(reducedNum) if evalRes { @@ -119,6 +121,8 @@ func (cmd *ConditionsCmd) Execute(_ context.Context, _ time.Time, vars mathexp.V } matches = append(matches, match) firingCount++ + } else if thisCondNoDataFound { + nilReducedCount++ } } diff --git a/pkg/expr/classic/classic_test.go b/pkg/expr/classic/classic_test.go index 74f6a09eda2..266640fc1bc 100644 --- a/pkg/expr/classic/classic_test.go +++ b/pkg/expr/classic/classic_test.go @@ -203,9 +203,8 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) - // This seems incorrect - v.SetMeta([]EvalMatch{{}, {Metric: "NoData"}}) + v := newNumber(ptr.Float64(1)) + v.SetMeta([]EvalMatch{{Value: nil}}) return newResults(v) }, }, { @@ -226,9 +225,9 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) + v := newNumber(ptr.Float64(1)) // This too seems incorrect, looks like we don't call the evaluator - v.SetMeta([]EvalMatch{{Metric: "NoData"}}) + v.SetMeta([]EvalMatch{{Value: nil}}) return newResults(v) }, }, { @@ -251,9 +250,9 @@ func TestConditionsCmd(t *testing.T) { }, }, expected: func() mathexp.Results { - v := newNumber(nil) + v := newNumber(ptr.Float64(1)) // This seems incorrect - v.SetMeta([]EvalMatch{{}, {Metric: "NoData"}}) + v.SetMeta([]EvalMatch{{Value: nil}}) return newResults(v) }, }, { From 38b980bd818e005fa82c920867c37bbf99977820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 18 Nov 2022 10:35:27 +0100 Subject: [PATCH 299/926] SceneObject: Prevent state mutation by using Object.freeze (#58936) --- .../features/scenes/core/SceneObjectBase.test.ts | 14 ++++++++++++++ .../app/features/scenes/core/SceneObjectBase.tsx | 10 ++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/public/app/features/scenes/core/SceneObjectBase.test.ts b/public/app/features/scenes/core/SceneObjectBase.test.ts index c8d3f138e76..e36d5479388 100644 --- a/public/app/features/scenes/core/SceneObjectBase.test.ts +++ b/public/app/features/scenes/core/SceneObjectBase.test.ts @@ -76,6 +76,20 @@ describe('SceneObject', () => { expect(clone.state.name).toBe('new name'); }); + it('Cannot modify state', () => { + const scene = new TestScene({ name: 'name' }); + expect(() => { + scene.state.name = 'new name'; + }).toThrow(); + + scene.setState({ name: 'new name' }); + expect(scene.state.name).toBe('new name'); + + expect(() => { + scene.state.name = 'other name'; + }).toThrow(); + }); + describe('When activated', () => { const scene = new TestScene({ $data: new SceneDataNode({}), diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index f0c5f75b27f..ffb997e5a1d 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -32,7 +32,7 @@ export abstract class SceneObjectBase) { const prevState = this._state; - this._state = { + const newState: TState = { ...this._state, ...update, }; + this._state = Object.freeze(newState); + this.setParent(); - this._subject.next(this._state); + this._subject.next(newState); // Bubble state change event. This is event is subscribed to by UrlSyncManager and UndoManager this.publishEvent( new SceneObjectStateChangedEvent({ prevState, - newState: this._state, + newState, partialUpdate: update, changedObject: this, }), From 0e4108f62f1e9a03a0097f2b8c1236582cac5cfd Mon Sep 17 00:00:00 2001 From: Alex Pakalniskis <43630382+alex-pakalniskis@users.noreply.github.com> Date: Fri, 18 Nov 2022 02:34:25 -0800 Subject: [PATCH 300/926] Documentation Update: Minor spelling change (#58933) chore: minor grammar tweak --- .../alerting/alerting-rules/create-grafana-managed-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md index 67d9736dc13..786de9cf53b 100644 --- a/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md +++ b/docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md @@ -15,7 +15,7 @@ weight: 400 # Create a Grafana managed alerting rule -Grafana allows you to create alerting rules that query one or more data sources, reduce or transform the results and compare them to each other or to fix thresholds. When these are executed, Grafana sends notifications to the contact point. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting. +Grafana allows you to create alerting rules that query one or more data sources, reduce or transform the results and compare them to each other or to fixed thresholds. When these are executed, Grafana sends notifications to the contact point. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting. Watch this video to learn more about creating alerts: {{< vimeo 720001934 >}} From 8e19a1618fd6547189ffc015cb5329e914e484ab Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 18 Nov 2022 10:46:50 +0000 Subject: [PATCH 301/926] QueryData: skip header validation (revert check) (#58871) --- pkg/services/query/query.go | 3 ++- pkg/services/query/query_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 773d0577f43..cefd648e19e 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -372,7 +372,8 @@ func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUse req.httpRequest = reqDTO.HTTPRequest } - return req, req.validateRequest() + _ = req.validateRequest() + return req, nil // TODO req.validateRequest() } func (s *Service) getDataSourceFromQuery(ctx context.Context, user *user.SignedInUser, skipCache bool, query *simplejson.Json, history map[string]*datasources.DataSource) (*datasources.DataSource, error) { diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index e8da4ab6481..af81ea8f8b4 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -189,7 +189,7 @@ func TestParseMetricRequest(t *testing.T) { httpreq.Header.Add("X-Datasource-Uid", "gIEkMvIVz") mr.HTTPRequest = httpreq _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr) - require.Error(t, err) + require.NoError(t, err) // With the second value it is OK httpreq.Header.Add("X-Datasource-Uid", "sEx6ZvSVk") From e823a90b82addd7350aa7241d2d5007d8c94ceb7 Mon Sep 17 00:00:00 2001 From: sfranzis Date: Fri, 18 Nov 2022 12:13:31 +0100 Subject: [PATCH 302/926] GaugePanel: Setting the neutral-point of a gauge (#53989) --- .../src/components/Gauge/Gauge.test.tsx | 3 + .../grafana-ui/src/components/Gauge/Gauge.tsx | 1 + public/app/plugins/panel/gauge/module.tsx | 15 +++- public/vendor/flot/jquery.flot.gauge.js | 78 +++++++++++++++++-- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx index 0a337707c36..00037a9872c 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx @@ -19,6 +19,9 @@ const field: FieldConfig = { mode: ThresholdsMode.Absolute, steps: [{ value: -Infinity, color: '#7EB26D' }], }, + custom: { + neeutral: 0, + }, }; const props: Props = { diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 39e7a80808e..1f54b72aff4 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -98,6 +98,7 @@ export class Gauge extends PureComponent { gauge: { min, max, + neutralValue: field.custom?.neutral, background: { color: backgroundColor }, border: { color: null }, shadow: { show: false }, diff --git a/public/app/plugins/panel/gauge/module.tsx b/public/app/plugins/panel/gauge/module.tsx index 3a36fd460a2..0910083e231 100644 --- a/public/app/plugins/panel/gauge/module.tsx +++ b/public/app/plugins/panel/gauge/module.tsx @@ -9,11 +9,22 @@ import { PanelOptions, defaultPanelOptions } from './models.gen'; import { GaugeSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(GaugePanel) - .useFieldConfig() + .useFieldConfig({ + useCustomConfig: (builder) => { + builder.addNumberInput({ + path: 'neutral', + name: 'Neutral', + description: 'Leave empty to use Min as neutral point', + category: ['Gauge'], + settings: { + placeholder: 'auto', + }, + }); + }, + }) .setPanelOptions((builder) => { addStandardDataReduceOptions(builder); addOrientationOption(builder); - builder .addBooleanSwitch({ path: 'showThresholdLabels', diff --git a/public/vendor/flot/jquery.flot.gauge.js b/public/vendor/flot/jquery.flot.gauge.js index 760354cecd4..8c5c43a9213 100644 --- a/public/vendor/flot/jquery.flot.gauge.js +++ b/public/vendor/flot/jquery.flot.gauge.js @@ -325,9 +325,9 @@ */ Gauge.prototype.drawGauge = function(gaugeOptionsi, layout, cellLayout, label, data) { - var blur = gaugeOptionsi.gauge.shadow.show ? gaugeOptionsi.gauge.shadow.blur : 0; - + var color = getColor(gaugeOptionsi, data); + var angles = calculateAnglesForGauge(gaugeOptionsi, layout, data); // draw gauge frame drawArcWithShadow( @@ -343,19 +343,74 @@ blur); // draw gauge - var c1 = getColor(gaugeOptionsi, data); - var a2 = calculateAngle(gaugeOptionsi, layout, data); drawArcWithShadow( cellLayout.cx, // center x cellLayout.cy, // center y layout.radius - 1, layout.width - 2, - toRad(gaugeOptionsi.gauge.startAngle), - toRad(a2), - c1, // line color + toRad(angles.a1), + toRad(angles.a2), + color, 1, // line width - c1, // fill color + color, // fill color blur); + + if(gaugeOptionsi.gauge.neutralValue != null) { + drawZeroMarker(gaugeOptionsi, layout, cellLayout, color); + } + } + + /** + * Calcualte the angles for the gauge, depending on if there are + * negative numbers or not. + * + * @method calculateAnglesForGauge + * @param {Object} gaugeOptionsi the options of the gauge + * @param {Number} data the value of the gauge + * @returns {Object} + */ + function calculateAnglesForGauge(gaugeOptionsi, layout, data) { + let angles = {}; + var neutral = gaugeOptionsi.gauge.neutralValue; + + if (neutral != null) { + if (data < neutral) { + angles.a1 = calculateAngle(gaugeOptionsi, layout, data); + angles.a2 = calculateAngle(gaugeOptionsi, layout, neutral); + } else { + angles.a1 = calculateAngle(gaugeOptionsi, layout, neutral); + angles.a2 = calculateAngle(gaugeOptionsi, layout, data); + } + } else { + angles.a1 = gaugeOptionsi.gauge.startAngle; + angles.a2 = calculateAngle(gaugeOptionsi, layout, data); + } + + return angles; + } + + /** + * Draw zero marker for Gauge with negative values + * + * @method drawZeroMarker + * @param {Object} gaugeOptionsi the options of the gauge + * @param {Object} layout the layout properties + * @param {Object} cellLayout the cell layout properties + * @param {String} color line color + */ + function drawZeroMarker(gaugeOptionsi, layout, cellLayout, color) { + var diff = (gaugeOptionsi.gauge.max - gaugeOptionsi.gauge.min) / 600; + + drawArc(context, + cellLayout.cx, + cellLayout.cy, + layout.radius - 2, + layout.width - 4, + toRad(calculateAngle(gaugeOptionsi, layout, gaugeOptionsi.gauge.neutralValue-diff)), + toRad(calculateAngle(gaugeOptionsi, layout, gaugeOptionsi.gauge.neutralValue+diff)), + color, + 2, + gaugeOptionsi.gauge.background.color); } /** @@ -529,6 +584,13 @@ drawThresholdValue(gaugeOptionsi, layout, cellLayout, i + "_" + j, threshold.value, a); } } + + var neutral = gaugeOptionsi.gauge.neutralValue; + if (neutral != null && + neutral>gaugeOptionsi.gauge.min && + neutral Date: Fri, 18 Nov 2022 15:37:18 +0200 Subject: [PATCH 303/926] CI: Move `upload-cdn` subcommand from `grabpl` (#58957) Move upload-cdn from grabpl --- .drone.yml | 16 ++++---- pkg/build/cmd/main.go | 8 ++++ pkg/build/cmd/uploadcdn.go | 75 ++++++++++++++++++++++++++++++++++++ scripts/drone/steps/lib.star | 2 +- 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 pkg/build/cmd/uploadcdn.go diff --git a/.drone.yml b/.drone.yml index 103256ecf2f..4b48aa009e0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1569,7 +1569,7 @@ steps: repo: - grafana/grafana - commands: - - ./bin/grabpl upload-cdn --edition oss + - ./bin/build upload-cdn --edition oss depends_on: - grafana-server environment: @@ -2135,7 +2135,7 @@ steps: event: - tag - commands: - - ./bin/grabpl upload-cdn --edition oss + - ./bin/build upload-cdn --edition oss depends_on: - grafana-server environment: @@ -2785,7 +2785,7 @@ steps: - success - failure - commands: - - ./bin/grabpl upload-cdn --edition enterprise + - ./bin/build upload-cdn --edition enterprise depends_on: - package environment: @@ -2826,7 +2826,7 @@ steps: image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - - ./bin/grabpl upload-cdn --edition enterprise2 + - ./bin/build upload-cdn --edition enterprise2 depends_on: - package-enterprise2 environment: @@ -4156,7 +4156,7 @@ steps: include: - packages/grafana-ui/** - commands: - - ./bin/grabpl upload-cdn --edition oss + - ./bin/build upload-cdn --edition oss depends_on: - grafana-server environment: @@ -4764,7 +4764,7 @@ steps: - success - failure - commands: - - ./bin/grabpl upload-cdn --edition enterprise + - ./bin/build upload-cdn --edition enterprise depends_on: - package environment: @@ -4812,7 +4812,7 @@ steps: image: grafana/build-container:1.6.4 name: package-enterprise2 - commands: - - ./bin/grabpl upload-cdn --edition enterprise2 + - ./bin/build upload-cdn --edition enterprise2 depends_on: - package-enterprise2 environment: @@ -5512,6 +5512,6 @@ kind: secret name: packages_secret_access_key --- kind: signature -hmac: 77ae647c9addfcd9966d462ca9967b85a87e18adfe0e9e0a2c6b1cf5d7f42493 +hmac: bdde811590573d22162d8305ced15080e8b20f2180b7491891c461427810a4b3 ... diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index 46d8ef8f32a..ba4f1f6eb0a 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -96,6 +96,14 @@ func main() { }, }, }, + { + Name: "upload-cdn", + Usage: "Upload public/* to a cdn bucket", + Action: UploadCDN, + Flags: []cli.Flag{ + &editionFlag, + }, + }, { Name: "shellcheck", Usage: "Run shellcheck on shell scripts", diff --git a/pkg/build/cmd/uploadcdn.go b/pkg/build/cmd/uploadcdn.go new file mode 100644 index 00000000000..e9c4b72acab --- /dev/null +++ b/pkg/build/cmd/uploadcdn.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/grafana/grafana/pkg/build/config" + "github.com/grafana/grafana/pkg/build/gcloud/storage" + "github.com/urfave/cli/v2" +) + +// UploadCDN implements the sub-command "upload-cdn". +func UploadCDN(c *cli.Context) error { + if c.NArg() > 0 { + if err := cli.ShowSubcommandHelp(c); err != nil { + return cli.NewExitError(err.Error(), 1) + } + return cli.NewExitError("", 1) + } + + metadata, err := GenerateMetadata(c) + if err != nil { + return err + } + + version := metadata.GrafanaVersion + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + buildConfig, err := config.GetBuildConfig(metadata.ReleaseMode.Mode) + if err != nil { + return err + } + + edition := os.Getenv("EDITION") + log.Printf("Uploading Grafana CDN Assets, version %s, %s edition...", version, edition) + + editionPath := "" + + switch config.Edition(edition) { + case config.EditionOSS: + editionPath = "grafana-oss" + case config.EditionEnterprise: + editionPath = "grafana" + case config.EditionEnterprise2: + editionPath = os.Getenv("ENTERPRISE2_CDN_PATH") + default: + panic(fmt.Sprintf("unrecognized edition %q", edition)) + } + + gcs, err := storage.New() + if err != nil { + return err + } + + bucket := gcs.Bucket(buildConfig.Buckets.CDNAssets) + srcPath := buildConfig.Buckets.CDNAssetsDir + srcPath = filepath.Join(srcPath, editionPath, version) + + if err := gcs.DeleteDir(c.Context, bucket, srcPath); err != nil { + return err + } + log.Printf("Successfully cleaned source: %s/%s\n", buildConfig.Buckets.CDNAssets, srcPath) + + if err := gcs.CopyLocalDir(c.Context, "./public", bucket, srcPath, false); err != nil { + return err + } + + log.Printf("Successfully uploaded cdn static assets to: %s/%s!\n", buildConfig.Buckets.CDNAssets, srcPath) + + return nil +} diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index d185b6fe567..bf45fc1a63d 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -354,7 +354,7 @@ def upload_cdn_step(edition, ver_mode, trigger=None): 'PRERELEASE_BUCKET': from_secret(prerelease_bucket) }, 'commands': [ - './bin/grabpl upload-cdn --edition {}'.format(edition), + './bin/build upload-cdn --edition {}'.format(edition), ], } if trigger and ver_mode in ("release-branch", "main"): From b3406a8273122cca702fd2e13c550bb1b0befc99 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Fri, 18 Nov 2022 14:40:26 +0100 Subject: [PATCH 304/926] Auth: Remove userauth service (#58941) * Auth: remove userauth service * Use Revoke user tokens from UserAuthTokenService * Add function to delete user auth info to UserAuthInfo service --- pkg/api/admin_users.go | 4 +- pkg/api/http_server.go | 6 +-- pkg/cmd/grafana-cli/runner/wire.go | 4 +- pkg/server/wire.go | 2 - pkg/services/login/authinfo.go | 1 + .../authinfoservice/database/database.go | 8 ++++ pkg/services/login/authinfoservice/service.go | 4 ++ pkg/services/login/logintest/logintest.go | 4 ++ pkg/services/userauth/userauth.go | 8 ---- pkg/services/userauth/userauthimpl/store.go | 32 ---------------- .../userauth/userauthimpl/store_test.go | 31 ---------------- .../userauth/userauthimpl/userauth.go | 28 -------------- .../userauth/userauthimpl/userauth_test.go | 37 ------------------- pkg/services/userauth/userauthtest/fake.go | 19 ---------- 14 files changed, 21 insertions(+), 167 deletions(-) delete mode 100644 pkg/services/userauth/userauth.go delete mode 100644 pkg/services/userauth/userauthimpl/store.go delete mode 100644 pkg/services/userauth/userauthimpl/store_test.go delete mode 100644 pkg/services/userauth/userauthimpl/userauth.go delete mode 100644 pkg/services/userauth/userauthimpl/userauth_test.go delete mode 100644 pkg/services/userauth/userauthtest/fake.go diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index bf284cddb33..54855e7d281 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -239,13 +239,13 @@ func (hs *HTTPServer) AdminDeleteUser(c *models.ReqContext) response.Response { return nil }) g.Go(func() error { - if err := hs.userAuthService.Delete(ctx, cmd.UserID); err != nil { + if err := hs.authInfoService.DeleteUserAuthInfo(ctx, cmd.UserID); err != nil { return err } return nil }) g.Go(func() error { - if err := hs.userAuthService.DeleteToken(ctx, cmd.UserID); err != nil { + if err := hs.AuthTokenService.RevokeAllUserTokens(ctx, cmd.UserID); err != nil { return err } return nil diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 7320b71b2cd..222b6923990 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/querylibrary" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/store/object/httpobjectstore" - "github.com/grafana/grafana/pkg/services/userauth" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -207,7 +206,6 @@ type HTTPServer struct { accesscontrolService accesscontrol.Service annotationsRepo annotations.Repository tagService tag.Service - userAuthService userauth.Service oauthTokenService oauthtoken.OAuthTokenService } @@ -250,8 +248,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service, accesscontrolService accesscontrol.Service, dashboardThumbsService thumbs.DashboardThumbService, navTreeService navtree.Service, annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, - userAuthService userauth.Service, queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, - oauthTokenService oauthtoken.OAuthTokenService, + queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -353,7 +350,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi accesscontrolService: accesscontrolService, annotationsRepo: annotationRepo, tagService: tagService, - userAuthService: userAuthService, QueryLibraryHTTPService: queryLibraryHTTPService, QueryLibraryService: queryLibraryService, oauthTokenService: oauthTokenService, diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 8ee0af3f6a5..975264bba82 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -7,7 +7,6 @@ import ( "context" "github.com/google/wire" - "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/tsdb/parca" "github.com/grafana/grafana/pkg/tsdb/phlare" @@ -53,6 +52,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth" + "github.com/grafana/grafana/pkg/services/auth/authimpl" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/comments" @@ -129,7 +129,6 @@ import ( "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/services/userauth/userauthimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" "github.com/grafana/grafana/pkg/tsdb/cloudmonitoring" @@ -327,7 +326,6 @@ var wireSet = wire.NewSet( userimpl.ProvideService, orgimpl.ProvideService, teamimpl.ProvideService, - userauthimpl.ProvideService, ngmetrics.ProvideServiceForTest, notifications.MockNotificationService, objectdummyserver.ProvideFakeObjectServer, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 4d2802abbe8..45c2c8f3d12 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -143,7 +143,6 @@ import ( "github.com/grafana/grafana/pkg/services/thumbs/dashboardthumbsimpl" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/services/userauth/userauthimpl" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" "github.com/grafana/grafana/pkg/tsdb/cloudmonitoring" @@ -368,7 +367,6 @@ var wireBasicSet = wire.NewSet( teamimpl.ProvideService, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, - userauthimpl.ProvideService, secretsMigrations.ProvideDataSourceMigrationService, secretsMigrations.ProvideMigrateToPluginService, secretsMigrations.ProvideMigrateFromPluginService, diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 3568b883c46..e8484c781ba 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -13,6 +13,7 @@ type AuthInfoService interface { GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error SetAuthInfo(ctx context.Context, cmd *models.SetAuthInfoCommand) error UpdateAuthInfo(ctx context.Context, cmd *models.UpdateAuthInfoCommand) error + DeleteUserAuthInfo(ctx context.Context, userID int64) error } const ( diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index 395818e662e..a7fdf2fd65c 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -218,6 +218,14 @@ func (s *AuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *models.DeleteAu }) } +func (s *AuthInfoStore) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + return s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var rawSQL = "DELETE FROM user_auth WHERE user_id = ?" + _, err := sess.Exec(rawSQL, userID) + return err + }) +} + func (s *AuthInfoStore) GetUserById(ctx context.Context, id int64) (*user.User, error) { query := user.GetUserByIDQuery{ID: id} user, err := s.userService.GetByID(ctx, &query) diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index ac713a7345b..3a6025bd491 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -197,6 +197,10 @@ func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query * return s.authInfoStore.GetExternalUserInfoByLogin(ctx, query) } +func (s *Implementation) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + return nil +} + func (s *Implementation) Run(ctx context.Context) error { s.logger.Debug("Started AuthInfo Metrics collection service") return s.authInfoStore.RunMetricsCollection(ctx) diff --git a/pkg/services/login/logintest/logintest.go b/pkg/services/login/logintest/logintest.go index 5c2ce4005df..023ae063f13 100644 --- a/pkg/services/login/logintest/logintest.go +++ b/pkg/services/login/logintest/logintest.go @@ -57,6 +57,10 @@ func (a *AuthInfoServiceFake) GetExternalUserInfoByLogin(ctx context.Context, qu return a.ExpectedError } +func (a *AuthInfoServiceFake) DeleteUserAuthInfo(ctx context.Context, userID int64) error { + return a.ExpectedError +} + type AuthenticatorFake struct { ExpectedUser *user.User ExpectedError error diff --git a/pkg/services/userauth/userauth.go b/pkg/services/userauth/userauth.go deleted file mode 100644 index e0cb1f5c7ee..00000000000 --- a/pkg/services/userauth/userauth.go +++ /dev/null @@ -1,8 +0,0 @@ -package userauth - -import "context" - -type Service interface { - Delete(context.Context, int64) error - DeleteToken(context.Context, int64) error -} diff --git a/pkg/services/userauth/userauthimpl/store.go b/pkg/services/userauth/userauthimpl/store.go deleted file mode 100644 index e563eb396fe..00000000000 --- a/pkg/services/userauth/userauthimpl/store.go +++ /dev/null @@ -1,32 +0,0 @@ -package userauthimpl - -import ( - "context" - - "github.com/grafana/grafana/pkg/infra/db" -) - -type store interface { - Delete(context.Context, int64) error - DeleteToken(context.Context, int64) error -} - -type sqlStore struct { - db db.DB -} - -func (ss *sqlStore) Delete(ctx context.Context, userID int64) error { - return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var rawSQL = "DELETE FROM user_auth WHERE user_id = ?" - _, err := sess.Exec(rawSQL, userID) - return err - }) -} - -func (ss *sqlStore) DeleteToken(ctx context.Context, userID int64) error { - return ss.db.WithDbSession(ctx, func(sess *db.Session) error { - var rawSQL = "DELETE FROM user_auth_token WHERE user_id = ?" - _, err := sess.Exec(rawSQL, userID) - return err - }) -} diff --git a/pkg/services/userauth/userauthimpl/store_test.go b/pkg/services/userauth/userauthimpl/store_test.go deleted file mode 100644 index 5b29b735666..00000000000 --- a/pkg/services/userauth/userauthimpl/store_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package userauthimpl - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" -) - -func TestIntegrationUserAuthDataAccess(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - ss := db.InitTestDB(t) - userAuthStore := sqlStore{ - db: ss, - } - - t.Run("delete user auth", func(t *testing.T) { - err := userAuthStore.Delete(context.Background(), 1) - require.NoError(t, err) - }) - - t.Run("delete user auth token", func(t *testing.T) { - err := userAuthStore.DeleteToken(context.Background(), 1) - require.NoError(t, err) - }) -} diff --git a/pkg/services/userauth/userauthimpl/userauth.go b/pkg/services/userauth/userauthimpl/userauth.go deleted file mode 100644 index 23367fb9ea6..00000000000 --- a/pkg/services/userauth/userauthimpl/userauth.go +++ /dev/null @@ -1,28 +0,0 @@ -package userauthimpl - -import ( - "context" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/userauth" -) - -type Service struct { - store store -} - -func ProvideService(db db.DB) userauth.Service { - return &Service{ - store: &sqlStore{ - db: db, - }, - } -} - -func (s *Service) Delete(ctx context.Context, userID int64) error { - return s.store.Delete(ctx, userID) -} - -func (s *Service) DeleteToken(ctx context.Context, userID int64) error { - return s.store.DeleteToken(ctx, userID) -} diff --git a/pkg/services/userauth/userauthimpl/userauth_test.go b/pkg/services/userauth/userauthimpl/userauth_test.go deleted file mode 100644 index 7c29f57d422..00000000000 --- a/pkg/services/userauth/userauthimpl/userauth_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package userauthimpl - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestUserAuthService(t *testing.T) { - userAuthStore := &FakeUserAuthStore{} - userAuthService := Service{ - store: userAuthStore, - } - - t.Run("delete user", func(t *testing.T) { - err := userAuthService.Delete(context.Background(), 1) - require.NoError(t, err) - }) - - t.Run("delete token", func(t *testing.T) { - err := userAuthService.DeleteToken(context.Background(), 1) - require.NoError(t, err) - }) -} - -type FakeUserAuthStore struct { - ExpectedError error -} - -func (f *FakeUserAuthStore) Delete(ctx context.Context, userID int64) error { - return f.ExpectedError -} - -func (f *FakeUserAuthStore) DeleteToken(ctx context.Context, userID int64) error { - return f.ExpectedError -} diff --git a/pkg/services/userauth/userauthtest/fake.go b/pkg/services/userauth/userauthtest/fake.go deleted file mode 100644 index d7a3b0bb7d7..00000000000 --- a/pkg/services/userauth/userauthtest/fake.go +++ /dev/null @@ -1,19 +0,0 @@ -package userauthtest - -import "context" - -type FakeUserAuthService struct { - ExpectedError error -} - -func NewFakeUserAuthService() *FakeUserAuthService { - return &FakeUserAuthService{} -} - -func (f *FakeUserAuthService) Delete(ctx context.Context, userID int64) error { - return f.ExpectedError -} - -func (f *FakeUserAuthService) DeleteToken(ctx context.Context, userID int64) error { - return f.ExpectedError -} From 44e8fb628ed2dbb66361813b0d480941a879bf5c Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Fri, 18 Nov 2022 14:52:54 +0100 Subject: [PATCH 305/926] Explore: Fix a11y issue with show all series button in Graph (#58943) * Explore: Fix a11y issue with show all series button in Graph * remove extra space * add spacing --- .../features/explore/Graph/ExploreGraph.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index f8a0e02f0d4..ce4ec43e2c3 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -22,6 +22,7 @@ import { import { PanelRenderer } from '@grafana/runtime'; import { GraphDrawStyle, LegendDisplayMode, TooltipDisplayMode, SortOrder } from '@grafana/schema'; import { + Button, Icon, PanelContext, PanelContextProvider, @@ -166,13 +167,15 @@ export function ExploreGraph({ {dataWithConfig.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
    - {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `} - { - setShowAllTimeSeries(true); - }} - >{`Show all ${dataWithConfig.length}`} + Showing only {MAX_NUMBER_OF_TIME_SERIES} time series. +
    )} ({ timeSeriesDisclaimer: css` label: time-series-disclaimer; - width: 300px; margin: ${theme.spacing(1)} auto; padding: 10px 0; border-radius: ${theme.spacing(2)}; @@ -204,9 +206,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ color: ${theme.colors.warning.main}; margin-right: ${theme.spacing(0.5)}; `, - showAllTimeSeries: css` - label: show-all-time-series; - cursor: pointer; - color: ${theme.colors.text.link}; + showAllButton: css` + margin-left: ${theme.spacing(0.5)}; `, }); From b68fe6336a89cdb17cf0eb2b97ecf590f0c9b9a5 Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Fri, 18 Nov 2022 14:54:20 +0100 Subject: [PATCH 306/926] Chore: move keydown handler in rich history card (#58945) Chore: move kedown handler in rich history card --- public/app/features/explore/RichHistory/RichHistoryCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx index d67432a6226..882057b2928 100644 --- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx @@ -260,6 +260,7 @@ export function RichHistoryCard(props: Props) { const updateComment = (